Changelog¶
All notable changes to the RWA Calculator are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
Added¶
- The release now gates the artifact it is about to publish, not just the
source tree it was built from. v0.3.25 was tagged, released and announced
before anything discovered that its wheel could not be uploaded:
uv buildresolves the build backend fresh and the current backend emitsMetadata-Version: 2.5, whilepypa/gh-action-pypi-publishwas pinned at v1.14.0, whose Twine 6 rejects it. Nothing in this repository had changed.scripts/check_distribution.pynow reads the metadata version out of every built wheel and sdist, reads the pinned publisher version out ofpublish.yml, and fails when the first outruns what the second accepts — the two components float independently, so only reading them together sees the skew. It runs fromdeploy.py::build_releaseand from the CIbuildjob, withuvx twine check dist/*alongside it for the malformed-distribution class. Worth stating plainly, because it is the counter-intuitive part:twine checkon its own does not catch this, since any Twine installable today accepts 2.5 and is therefore green on exactly the wheel that fails. -
A version-stamped generator can no longer be left out of the release. Three generated targets embed the package version, so bumping it is by itself enough to make them stale — and
deploy.pyruns the test suite before the bump, so the suite measures a tree in which the staleness does not yet exist.tests/contracts/test_release_regeneration.pydiscovers version-stamped generators by inspection rather than from a list, and fails when one is not regenerated by the release script; a companion test fails if the sweep ever comes back empty, so a drifted heuristic cannot pass vacuously. -
arch_checkcheck 19: notype=PathCLI argument inscripts/. An operator-supplied string used to construct a path is an arbitrary-file-read sink, which SonarCloud reports aspythonsecurity:S8707and which fails thenew_security_ratingquality gate outright. Three scripts had shipped the construct and each was fixed individually, so the rule was graduated out of prose into a check. Deliberately not fixed with a containment guard: commita5d34c0drecords that route being tried twice, producing correct code both times, with the taint engine reporting the finding regardless. Usechoices=over a literalPathmapping, or read the fixed location and let callers import the function.
Running it for the first time found nine instances nobody had counted,
across coverage_report.py, defect_injection.py, impact_report.py and
parity_gate.py. They ship as a shrink-only CLI_PATH_ARG_ALLOWLIST and are
filed for draining rather than fixed here. Note coverage_report.py is on
that list despite an earlier commit having already fixed this rule in the same
file — the previous pass removed one argument and left another, which is what
per-instance fixing looks like from the outside.
Changed¶
- Both escapes are written up in
docs/development/escape-log.md, each with an escape class, a named gate change and a red observed against the genuine pre-fix commit —6f513697for the regeneration hole,451e97dbfor the metadata skew. The first carries a note on thegate-not-runclass: its prescription reads "move the gate earlier", and here the fix is to move it later, after the mutation it should catch.
[0.3.25] - 2026-08-11¶
Added¶
- The correctness estate now gates instead of merely measuring. The independent validation layers merged on 2026-08-08 could all report a blind spot and none of them could fail a build over one. Six changes close that:
-
Coverage is a gate.
scripts/coverage_report.py --checkruns in CI as thecoverage-ratchetjob and is mirrored for local development bytests/contracts/test_coverage_ratchet.py, one of whose tests asserts that CI still invokes the script — the failure being fixed here was an inert ratchet, not a missing one. Six metrics inscripts/coverage_baseline.jsonratchet directionally: published-rule binding counts, live cells and template-cell liveness may not fall, dead cells and never-evaluated rules may not rise. Measured over the current 16-run matrix, the estate stands at 12.85% template-cell liveness (8,193 live cells of 63,746 declared), 55,553 dead cells, 785 never-evaluated rules, and 257 (CRR) / 289 (Basel 3.1) published rules binding, and the baseline now carries aprovenanceblock naming the runs behind those numbers — so a matrix change reports as INVALID rather than as a regression, and the right response is to re-measure rather than to drop a portfolio until the old numbers fit. The figures it replaces reproduced at neither the matrix they were taken on nor today's: they had stopped describing the estate before the matrix grew, and nothing could tell, because a baseline that records no matrix cannot say what it describes. The same pass addscells_liveas a genuine floor at 8,193: a ratio whose denominator shrinks with its numerator is not a floor, and neither is a count of the complement — dropping any region less live than the estate average improved both cell metrics while real coverage fell. The floor is in the ratchet's metric list ahead of being banked in the baseline, so--checkraises on the missing key until the accompanying re-bank lands.Note the scope. Every one of these metrics is value-insensitive: liveness counts cells that are non-null, and "binding" counts a rule that reaches
PASSorFAIL. This gate therefore makes blind spots visible and cannot see a wrong number in a cell that stays populated — that remains the supervisory register's job. - Vacuity is ratcheted. A supervisory rule whose operands are all null or zero evaluates toVACUOUS, and that count was informational: a change that emptied a column flipped its rulesPASS→VACUOUSand left every test in the register green. A two-way ratchet over 218 baselined known-vacuous rules now fails aPASS→VACUOUSregression and requires the list to shrink when a rule activates, so a rule that is evaluable today cannot become unevaluable without a written reason. - The defect-injection harness runs whereuvdoes not.DEFECT_INJECTION_PYTHONretargets the whole gate ladder at a named interpreter through a single chokepoint, with a pre-flight import probe and a hard error on any command it cannot rewrite. Previously every gate was spawned viauv run; where that path is unusable each gate fails to spawn, every failure scores as a detection, and the harness publishes a detection rate near 100% while detecting nothing. That is measured rather than theoretical — on the sandbox this was developed in,uv runexits 2 withCould not acquire lock … Read-only file system. The detection rate itself is still unpublished: no campaign has been scored yet. - Credit risk mitigation has independent oracle coverage — CRM joins the stdlib-only shadow calculator, which is the only layer that can catch a wrong constant rather than a changed one. - Independent cell re-derivation extends beyond OV1, so more published template cells are derived from instruction text rather than compared against recorded engine output. - CI fires on the branches the development loop actually pushes —batch/**,claude/**,feat/**and the rest, notmasteralone. The loop pushes to feature and per-item batch branches and only opens a PR at the end, so amaster-only trigger meant the majority of commits never met CI at all. - A defect found in output is now closed by its escape-log entry, not by its fix commit.docs/development/escape-log.mdhad a seven-class escape taxonomy, a "verified red" requirement, and zero entries while defects were reaching published output./postmortemnow refuses to conclude without an escape class, a named gate change and recorded evidence the new gate was observed red before the fix, with three permitted routes to producing that red; and the log is seeded with six entries. Four are the escapes this project had already established — the ungated coverage ratchet, unratcheted vacuity, the unmeasured detection rate, and oracle disagreements parked as strict xfails (a register that grew from four entries to eleven, eight of them understating capital, during the batch that wrote the entry describing that mechanism). That last one needed an eighth class,caught-and-parked: the other seven all answer why didn't a gate catch it, and there the gate caught it and the record of the finding became its resting place. The other two came out of writing them up — a measured escape (a term dropped from a C 02.00 subtotal, with a full supervisory run reporting8 passedalongside it, because the live ERROR rule that would catch it ships in this repo and is never evaluated), and a defect in a gate that had not yet shipped, which carries no class because all eight presume a defect that reached production. - The regulatory skills no longer state regulatory values.scripts/generate_regulatory_tables.pynow renders pack values into<!-- BEGIN/END GENERATED -->regions inside thebasel31andcrrskill reference files (33 fragments across 17 files), and the newscripts/check_skill_values.pyfails any percentage written into skill prose outside those regions, with a justifiedALLOWANCESlist for genuine exceptions (verbatim article quotations, published EBA rule expressions). Both are gated bytests/contracts/test_docs_freshness.py. Skill prose now carries only what the pack cannot — precedence, scope, mechanics, PRA-vs-BCBS divergences and traps — and names pack entries instead of quoting values. Awhat-changed.mdfragment renders the CRR↔B3.1 divergence table by comparing the resolved packs, so it maintains itself. The generator hard- fails (exit 2) on a fragment id with no marker, a marker no fragment fills, or an entry name in neither pack — so a pack rename cannot silently empty a skill. - Per-article confidence matrix.scripts/generate_confidence_matrix.pyjoins five evidence layers per regulatory article (@citessnapshot, cited pack entries, oracle records, a heuristic test scan, a source-name scan) into docs/development/confidence-matrix.md plus a machine-readable snapshot, tiered HIGH / MEDIUM / LOW / UNCITED / GAP. GAP (32 articles) is the actionable coverage-hole list; the SA-CCR cluster is correctly shown as implemented-but-uncitable (watchfire index gap), not as missing. Freshness-gated bytests/contracts/test_confidence_matrix_freshness.py. - Differential shadow fuzzing.tests/oracle/derivations/branch_sa.pyturns the shadow calculator into a callable stdlib-only SA branch oracle, andtests/properties/test_differential_shadow.pyfuzzes the engine against it: a 49-case deterministic matrix exhaustive over entity × CQS × framework plus a hypothesis fuzz on top. No engine/shadow disagreement found on any in-scope input under either regime; excluded branches are enumerated, never silent. - Cross-regime delta regression.tests/properties/test_regime_deltas.pyruns one all-SA portfolio under both CRR and Basel 3.1 and asserts a curated, cited delta map: 8 no-change legs (exact RW equality), 4 changed legs with exact values both sides (corporate CQS3 100%→75%, institution CQS2 50%→30%, SME rated/unrated via the Art. 501 supporting-factor removal), and a bookkeeping identity tying each regime's total RWA to the sum of per-leg deltas. Every value re-derived from the source PDFs in adversarial review. - Docs dead-link ratchet.scripts/check_doc_links.pycounts broken relative links and dead intra-page anchors acrossdocs/(76 at baseline, banked inscripts/docs_link_baseline.json) and two-way ratchets the count in the contract suite: a new dead link fails, a fixed one must be banked. Burn-down tracked as P4.56/P1.309. - Independent validation system. Six components addressing the fact that the estate's ~10,500 tests almost all compare against recorded engine output, so they detect change rather than wrongness. Plan: Independent Validation System. -tests/properties/— 185 regulation-derived properties (conservation, structural invariants, monotonicity, homogeneity, output-floor identities, template row-axis coverage). Finds wrongness without anyone deriving an expected value. 157s. -tests/oracle/— the independent shadow calculator grown from 3 to 132 exposures, stdlib-only and self-enforced bytest_derivations_never_import_rwa_calc. The only component that can catch a wrong constant. -tests/conformance/— an externally-authored classification decision table (710 combinations, 602 in scope, 2,396 assertions; an unmapped combination is a hard failure) and independent OV1 cell re-derivation from Annex II text. -scripts/impact_report.py— change-impact reporting over 128,127 template cells at four grains, with appeared/disappeared cells reported loudly and an allowlist requiring a written reason. -scripts/coverage_report.py+coverage_baseline.json— estate-wide published-rule and template-cell coverage, ratcheted. -scripts/defect_injection.py— defect-injection scorecard: 22 mutants, a data-driven gate ladder, and reachability as a first-class verdict.
Security¶
- An unrecognised report-template or run identifier is no longer written into
a log line. Three log statements interpolated a value taken straight from an
HTTP route or query parameter (
reporting/lineage.py,api/rest.py,ui/views/report_templates.py), which lets a caller forge log records (CWE-117). Each now echoes back the matching literal held by the server — the key stored in the run registry, the lineage plan registry, or the run's template catalogue — so a recognised id logs exactly as before, and an unrecognised one is replaced by<unknown>/<unregistered>rather than reproduced. Filtering control characters out of the caller's string (_safe_log_token, retained as a second layer) narrows what can be injected; substituting our own literal removes the caller's bytes from the record. - The pre-commit gate no longer permits a source distribution's setup scripts
to run.
scripts/pre_commit_gate.shinvokesuv runon every commit; both call sites now pass--no-build --no-sync, so the gate executes in the environment the developer already has instead of resolving (and potentially building) one. The two flags are a pair —--no-buildalone fails outright, because the editable local project has no binary distribution to install from. Re-sync explicitly withuv sync --all-groupsafter changing dependencies.
Changed¶
- The test suite no longer oversubscribes the machine: 10m50s → 3m56s on the
reference dev box, with identical results. Polars sizes its thread pool from
the core count the first time it is imported, and
pytest-xdistsized its worker fleet the same way via-n auto. On a 16-core box that was 16 workers each holding a 16-thread pool — 256 threads contending for 16 cores, each pool carrying its own buffers — while practically every test here pushes a handful of rows through the pipeline, so that parallelism bought nothing.
tests/conftest.py now sets POLARS_MAX_THREADS=1 (via os.environ.setdefault,
before the first import polars, so it applies in the controller and in every
xdist worker), and addopts pins -n 8 instead of -n auto because the
binding constraint is RAM rather than cores. Measured on the same commit:
652s → 237s wall, peak system memory 4819 MB → 4419 MB, with the failure
set unchanged. On a fully green tree the memory saving is larger — a
green-to-green comparison measured 7301 MB → 4350 MB peak and available
memory at the low-water mark rising from 508 MB to 3459 MB.
Two guards come with it. tests/contracts/test_polars_thread_cap.py asserts
the pool size actually agrees with the environment, because setting the
variable after the first import polars is a silent no-op that costs the
whole win while leaving every test green. And the CI benchmark job now exports
the real core count, so benchmarks keep measuring production-like throughput;
setdefault is what lets that override win. Note that
POLARS_MAX_THREADS=0 must never be used — Polars accepts it at import and
then panics at compute time with "Worker threads cannot be set to 0".
-v is also dropped from addopts: 11k verbose result lines were pushed
through the xdist protocol and scrolled past, and --tb=short already gives
the diagnostic on failure. Override per-invocation as usual (-n auto,
-n 0, -v); a single-file run is much faster serially — 13.4s → 2.3s for
tests/unit/test_ccf.py, since below roughly a dozen files the worker
startup costs more than the tests.
- One work queue. DOCS_IMPLEMENTATION_PLAN.md is retired: its open items
merged into IMPLEMENTATION_PLAN.md (Tier 5 is now the docs queue that
/next-docs drains; migrated items keep their D-codes), and the misfiled
code items it had accumulated moved into the code tiers. One list, one audit
cadence, no cross-file bookkeeping.
- docs/data-model/regulatory-tables.md is now generated. Rendered from
the resolved rulepacks by scripts/generate_regulatory_tables.py (all ~250
cited entries, CRR and Basel 3.1 side by side, citations included) and
freshness-gated by tests/contracts/test_docs_freshness.py — the page can no
longer drift from the packs. Never hand-edit it.
- .claude/LESSONS.md is now tracked in git. It never was, so it existed only in
the main checkout and was absent from every git worktree — meaning every agent
dispatched into a worktree was told to read it and got "file does not exist".
- Corrected LESSONS.md A2: sub-agents can read the regulatory PDFs. The
limitation is the Read tool (no pdftoppm), not the agent — pymupdf via Bash
works, and the entry was routing agents to transcriptions it elsewhere records as
wrong.
- Added hypothesis to the dev dependencies (portfolio search and shrinking).
Fixed¶
- PyPI publishing works again: the release action was too old to accept the
metadata the build now emits.
pypa/gh-action-pypi-publishwas pinned at v1.14.0, whose bundled Twine rejects core packaging metadata 2.5 —InvalidDistribution: '2.5' is not a valid metadata version— anduv buildresolves its build backend fresh, so the wheel started declaringMetadata-Version: 2.5without anything in this repository changing. v0.3.24 published on 5 August and v0.3.25 did not. Bumped to v1.14.2, which carries Twine 7 precisely to accept 2.5. The failure surfaced only at upload, after the version bump, tag and GitHub Release already existed — nothing in the local release flow builds and validates a distribution the way PyPI does. - The release script now regenerates every version-stamped generated artifact.
docs/data-model/regulatory-tables.md,docs/development/confidence-matrix.mdandtests/contracts/data/confidence_snapshot.jsoneach embed the package version, so bumping it is by itself enough to make all three stale and turn their freshness contract tests red.scripts/deploy.pyregenerated only the citation matrix and the dependency graph, and it runs the test suite before the bump — so the suite could not observe the staleness the bump was about to create, and the first thing to see it was CI on the release commit. The two generators now run inbuild_release, and the three targets are staged for the release commit alongsidetests/contracts/data/citation_snapshot.json, which the citation-matrix regeneration had likewise been writing but never staging. in two distinct ways. Four paired an unbounded whitespace run against a neighbour that could also match whitespace (\s*beside a dot-matches-all.*?, or beside a negated class), so on input that failed to match the engine retried every split of the gap; these now use negated classes, an anchored possessive run, or caller-side stripping.
The other two began an unanchored pattern with an unbounded run
(\s+and\s+, [A-Z_]+\(), so the search re-entered the same run at every
offset. That cost is paid across start positions, so neither a possessive
quantifier nor a lookbehind gate removes it — both were tried and measured no
better. The unbounded run is now gone from the head of each: the prerequisite
splitter matches a single \sand\s (surrounding whitespace was already
stripped by its caller), and the BoE label parser splits the cell on its |
terminator in Python and matches each segment anchored.
Affected: the docs heading scanner (scripts/check_doc_links.py), the BoE
label and severity cell parsers (scripts/extract_validation_rules.py), the
EBA dimensional-filter parser (reporting/validations/evaluate.py), the
prerequisite splitter (reporting/validations/rules.py) and the
qualified-reference parser (reporting/validations/scope.py).
Measured on pathological non-matching input, the two rewritten scans go from quadratic to flat — 500ms → 0.03ms at n=8,000. Every rewrite was checked for behavioural equivalence against the pattern it replaces: over the real committed rule extracts (1,011 EBA prerequisite strings, 539 of them carrying a conjunction, and 4,100 BoE label cells rebuilt from their parsed values — 0 divergences), by 200,000 randomised inputs per pattern, and by the docs dead-link census returning its banked count of 76 unchanged.
One case is deliberately narrowed: a BoE label segment no longer parses if
non-whitespace garbage precedes its kind token. The workbook does not produce
that shape — a segment begins with its kind token — and requiring it is the
more faithful reading of the documented format.
- PS1/26 Art. 154(4A)(b): the 10% IRB mortgage RWEA floor is now confined to
non-defaulted retail exposures secured by residential immovable property
(P1.319). The engine gated the floor on a bare MORTGAGE|RESIDENTIAL
substring match against exposure_class, so it also floored defaulted
retail mortgages, which the article excludes by name. The gate is now a
positive equality on retail_mortgage — the engine's closest available proxy
for the Art. 147(5B)(d)(ii) subclass — combined with a non-defaulted test on
the is_defaulted carrier.
This lowers RWA. The floor is only ever added to RWEA, so narrowing its
scope can only remove capital. Measured on the irb-classes Basel 3.1
portfolio: one affected exposure, −20,000.00 of RWEA. The Basel 3.1 output
floor does not bind on that portfolio (U-TREA 68.96m against 0.6 × S-TREA
35.83m), so the reduction is unoffset there; in general TREA = max(U-TREA,
floor) bounds the effect at no more than the row-level delta. Set containment
was proven (new ⊆ old), so no non-defaulted retail mortgage loses the
floor.
Two limbs of the original finding did not survive audit. commercial_mortgage
and residential_mortgage are SA-bound and cannot reach the IRB branch in
production. The article's UK-property condition remains unimplemented —
no property-country carrier exists on the sealed re_split_exit edge
(cp_country_code is the obligor's country) — and its oracle case stays a
strict xfail rather than being quietly claimed as covered.
Firms should note the residual: a retail obligor secured only on commercial
property is still classified retail_mortgage and still receives the floor,
because the classifier's property-collateral test spans both property types.
That is conservative, strictly narrower than the previous behaviour, and
tracked separately.
- Two wrong values in the basel31 skill, both now impossible to reintroduce.
Corporate CQS5 under Basel 3.1 was stated as 100% in three places
(references/sa-risk-weights.md and twice in references/what-changed.md)
against PS1/26 Art. 122(2) Table 6, the pack and the engine, all of which say
150%; and the QRRE limit was stated as GBP 100k against a pack value of
GBP 90k. Both were prose-only defects — the engine was always correct —
but the skills are read by every role-agent before it designs a scenario, and
an earlier defect of exactly this class (CRR institution CQS 2 quoted as the
Basel 3.1 ECRA 30% rather than 50%) demonstrably seeded a wrong scalar into
the P8.20 fixture. Recorded as a graduated lesson in .claude/LESSONS.md.
- Corporate risk-weight citation label. The CRR pack's
corporate_risk_weights citation (and the regime-delta suite's citations)
now reference CRR Art. 122 Table 6 — Table 5 is Art. 121's
unrated-institution table. Values were always correct; the pointer was not.
- Stale register note. The supervisory-validation register's
caution_on_plan_doc_definitions note described the already-fixed
C 07.00 col 0200 origin-basis defect in the present tense; rewritten
past-tense with the resolution in both the JSON register and the test's
REGISTER_NOTES.
- Nothing yet — the findings below are recorded as strict xfails, not fixed,
because each moves published template numbers and needs its own preserve-or-fix
decision.
Removed¶
- The four back-compat shells left by the Phase 4 stage migration are
deleted.
engine/classifier.py,engine/hierarchy.py,engine/re_splitter.pyandengine/fx_converter.pyhad been reduced to 23–29 lines apiece — a docstring, a re-export, and a module logger that could never emit — while their implementations lived inengine/stages/{classify, hierarchy,re_split,fx}/. No production code imported them; they were kept alive by 47 test and script files. Every importer now names the stage package directly, andrwa_calc.enginere-exportsHierarchyResolverfromstages.hierarchy.
This was masking a broken test.
test_p6_26_qrre_coupling_constant.py::test_qrre_coupling_todo_marker_removed
read Path(hierarchy_module.__file__).read_text() to assert a forbidden
TODO(qrre-coupling) marker was absent. Once the implementation moved,
__file__ resolved to the 28-line shell, so the guard scanned a file that
could never contain the marker and passed unconditionally. It now scans every
module in the stages/hierarchy/ package — verified to fail when the marker
is reintroduced into facility_undrawn.py. The private constant
_FACILITY_QRRE_COUPLED_COLUMNS was being re-exported through the shell
solely to satisfy that test's hasattr probe.
Two further artefacts went with them: tests/contracts/test_logging_contract.py
asserted the module-logger contract against the three shell paths (now the
real recipe modules), and _NAMESPACE_LOGGER_NAMES in
tests/integration/test_logging_pipeline.py was dead — defined, never read.
New arch_check check 18 bans pure re-export shells under engine/: a
module with no defs whose body is only imports plus a docstring, __future__,
__all__ or the module logger. REEXPORT_SHELL_ALLOWLIST is empty by design.
Known issues (found by the new validation layers, all recorded as strict xfails)¶
- A Basel 3.1 equity leg is calculated and then dropped.
risk_weightresolves to 2.5 (PS1/26 Art. 133) andrwa_pre_factor,rwa_post_factorandrwa_finalare all null. 3,750,000 of RWEA and 300,000 of own funds leave the submission with no error, no null cell and no failing published rule. - C 02.00 IRB class rows do not foot to their approach totals. ~13.6m (CRR) and
~14.6m (Basel 3.1) of A-IRB RWEA on the rich portfolio, and 6.56m of F-IRB
sovereign RWEA on the irb-classes portfolio, are counted in the approach total and
absent from every of-which row.
corporate_smeis never looked up; row 0310 is keyed oncentral_government, which is not anExposureClassvalue. - CRR Art. 121(1) Table 5 is never applied to the institution class. Unrated institutions take a flat 100% instead of their sovereign's ladder. Conservative at CQS 1-2, coincidentally correct at 3-5, and under-weighted at CQS 6 (100% against a required 150%).
- PS1/26 Art. 154(4A)(b)'s 10% floor is applied outside its scope — no
non-defaulted gate, no retail gate, no UK-property gate. The UK limb is not
mis-gated but unrepresentable: nothing under
engine/irb/reads an obligor or property country column. - Pillar 3
high_riskreaches no CR4/CR5 class row under Basel 3.1 —SA_DISCLOSURE_CLASSESrow 11 maps to an empty tuple. - Five classification defects (B31 large-corporate F-IRB aliases; QRRE
per-individual double-count;
sync_irb_exposure_classoverwriting the Art. 147(3) class so MDBs, international organisations and covered bonds cannot reach IRB).
[0.3.24] - 2026-08-05¶
Fixed¶
- Guaranteed exposures published their collateral once per leg. CRM physically
splits a guaranteed exposure into
__G_<guarantor>and__REMlegs, but the columns pro-rated onto those legs omitted every collateral valuation, so each leg inherited the full value. COREP C 08.01/02 cols 0180-0210, C 07.00 col 0130 and Pillar 3 CR7-A all bind those carriers on the origin basis — both legs land in one reported row — so an N-leg split disclosed N x the collateral actually held. The seven collateral valuations are now split pro-rata. No capital number moves:apply_collateralruns beforeapply_guarantees, so the SA Comprehensive-MethodE*is already spent by split time. The Art. 231 waterfall allocations (crm_alloc_*,total_collateral_for_lgd) are deliberately NOT split — their only post-split consumer is the blended LGD input floor, a rate over the unsplitead_for_crm, so splitting the numerator alone would have silently raised it.
Changed¶
- PyPI development status promoted from
2 - Pre-Alphato3 - Alpha.
[0.3.23] - 2026-08-05¶
Fixed¶
- CRM substitution: the covered part's exposure value and RWEA never followed it to the guarantor. A guaranteed exposure's covered part correctly left the obligor's sheet through the pre-CCF flow columns, but every exposure-value and RWEA column stayed keyed to the obligor. A £10m slotting loan guaranteed by a CQS1 corporate at 20% reported cols 0100/0110/0150 = £10m on the guarantor's C 07.00 sheet with col 0200 = 0 and col 0220 = 0 — the £2m of RWEA appeared nowhere on the template. With a 0%-RW sovereign guarantor col 0220 = 0 is right by coincidence, which is how it survived.
- Annex II is explicit. Col 0200 is the "exposure value after taking into account value adjustments, all credit risk mitigants and conversion factors"; substitution is a credit risk mitigant. Cols 0090/0100 say the covered part is "deducted from the obligor's exposure class and subsequently assigned to the protection provider's".
- Fixed across C 07.00, C 08.01, C 08.06, C 09.01, C 09.02, C 02.00, OV1 and CR10, which had to move together: several are defined by cross-reference to one another, so moving one alone makes two templates state different values for a quantity the regulator defines as identical.
- Geography follows the same split, as PS1/26 Annex II §3.4 ¶87 prescribes: original exposure pre-CCF by the immediate obligor, exposure value and RWEA by the ultimate obligor. A GB borrower guaranteed by a German institution now reports its exposure value on the DE sheet.
- A declined guarantee no longer migrates anything. Where the engine declines a guarantee because it would not improve capital (CRR Art. 193(1) — "no exposure ... shall produce a higher risk-weighted exposure amount ... than an otherwise identical exposure ... with no credit risk mitigation"), the exposure kept its own risk weight for capital but still moved class and approach for reporting, booking a substitution outflow and inflow for protection that was never recognised. A 70% slotting weight was published on the SA central-governments sheet, where no such weight exists.
- A substituted-away leg no longer distorts the slotting grid. C 08.06 and CR10 kept a covered part that had left the slotting approach, so a category holding one substituted and one plain leg reported the EAD-weighted blend of their weights — 60.5% inside a fixed Art. 153(5) 90% band, with CR10 printing 90% beside an implied 60.5%.
- The covered part no longer appears as the guarantor's specialised lending.
sl_typeis the obligor's characteristic, so a slotting-origin inflow was populating "of which: specialised lending" on a corporate guarantor's sheet. Annex II admits those rows only for exposures applying the Art. 122B treatment, which a substituted-in part does not. - Nineteen published EBA/BoE supervisory validation rules stop breaking, including four Error-severity. Zero new breaks across both regimes and all six portfolios.
[0.3.22] - 2026-08-03¶
Fixed¶
- C 08.01 / C 08.02 reported every guarantee twice — once in the CRM substitution block
and again in the CRM-in-LGD block. Cols 0150 (guarantees) and 0160 (credit
derivatives) bound
Sum("guaranteed_portion")behind the sameprotection_typepredicate cols 0040/0050 already use, so a single £400k guarantee was published as a £400k "(-)" substitution outflow at col 0040 and a £400k LGD mitigant at col 0150 on the same sheet. - The two blocks are mutually exclusive by instruction. Annex II partitions unfunded protection by effect — "Guarantees shall be reported in column 0040 where the adjustment is not made in the LGD. Where the adjustment is made in the LGD, the amount of the guarantee shall be reported in column 0150" — and the cols 0150-0210 heading bars the other side outright: "CRM techniques that have an impact on LGD estimates as a result of the application of the substitution effect of CRM techniques shall not be included in these columns". PS1/26 Annex II partitions the same population by named method: 0040/0050 are the Risk-Weight Substitution and Parameter Substitution Methods, 0150/0160 the Article 183 LGD Adjustment Method.
- The engine only ever produces the substitution half.
engine/irb/guarantee.py::apply_guarantee_substitutionimplements SA risk-weight substitution (Art. 235), parameter substitution (Art. 161(3) / CRE22.70-85) and double default (Art. 153(3)) — none of which is the LGD Adjustment Method. Cols 0150/0160 therefore now report the recorded constant0.0, the convention cols 0170-0173 already followed. The funded half of the block (cols 0180-0210, Art. 197/199 collateral) is untouched and keeps reporting: collateral genuinely is an LGD mitigant. - The two bindings did not even agree in magnitude. Cols 0040/0050 read the Annex II
block-capped twin (
c08_prot_guaranteed); cols 0150/0160 read the raw carrier. On a leg where the cap bit, the template published a full-value 0150 against a scaled-down 0040 with nothing to flag the divergence. - No golden file moved. Cols 0150/0160 are
0.0in every committed C 08.01/02 expected output because no reporting golden portfolio carries a guaranteed IRB leg — which is exactly why the duplication survived. The four new regression tests supply that missing coverage. - Known residual, tracked separately: Annex II routes double-default unfunded protection to col 0220 instead of col 0040, and a double-default leg is not substituted at all, so it should raise no outflow. Today such a leg reports in 0040/0070 and 0220. Correcting it moves the col 0090 waterfall, so it is not bundled here.
- The release changelog promoter truncated every multi-line bullet to its first line.
scripts/_deploy_changelog.pycollected only lines beginning-when moving[Unreleased]into a new version section, so wrapped continuation text and nested sub-bullets were silently dropped — a bullet running to dozens of lines promoted as a single unclosed sentence fragment. It caught the 0.3.21 release, where 118 lines of release notes across two bullets collapsed to two fragments; the notes were restored and the tag moved before either was pushed. The parser now accumulates each top-level bullet together with the indented lines that follow it. Every pre-existing unit test used single-line bullets, which is why the defect survived: the two new regression tests assert a wrapped bullet with nested sub-bullets, and a bullet containing a blank line, both promote intact.
[0.3.21] - 2026-08-02¶
Fixed¶
- The CRM substitution block on C 07.00 / C 08.01 / C 08.02 removed guaranteed exposure
more than once, and lost it entirely when the guarantor sat outside the reporting
template. Six published validation rules already shipped in this repo pin the
arithmetic and every one of them was breached. The whole block was uncovered — every
CRM-substitution cell in all 70+ frozen golden files was exactly
0.0, because no golden portfolio contained a guaranteed exposure that migrates exposure class. - Col 0070 is the block subtotal, not an independent sum. It was
Sum(guaranteed_portion)gated on "the guarantor's class differs from the obligor's", which reported 0 for a same-class guarantee while col 0040 still showed the amount, and ignored col 0060 (Art. 232 other funded credit protection) entirely. It is now0040 + 0050 + 0060— EBAv1663_m/v1665_mand BoEboe_b0747/boe_b0761, all live. - Col 0090 deducted the same money twice, subtracting both the 0040/0050/0060
breakdown and the 0070 subtotal. It is now
0020 - 0035 - 0070 + 0080— EBAv1662_m/v0347_mand BoEboe_b0746/boe_b0760. The Basel 3.1 col 0035 on-balance-sheet netting term is row-scoped:boe_b0746_1drops it on the off-balance-sheet row family, because Art. 166(3) netting of loans and deposits cannot reduce an off-balance-sheet row. The off-BS memo col 0100 re-derived the same arithmetic independently and carried the same defect; it now binds the same per-leg subtotal so the two cannot drift apart. - Same-class substitutions now produce a matching inflow. The inflow side was gated on a class change while the outflow side was not, so a guarantee whose guarantor sat in the obligor's own class reported an outflow with nothing coming back — money left the return. Annex II, both templates and both regimes: "Inflows and outflows within the same exposure classes … shall also be considered."
- Substitutions crossing the SA/IRB boundary now reach the right template. Each
template derived its inflow from its own approach-filtered population, so an IRB
exposure guaranteed by an SA counterparty was deducted on C 08.01 and added back
nowhere. A new
reporting/corep/crm_substitution.pycomputes the inflow once over the whole sealed population and routes it by the sealed post-substitution approach — SA guarantor to C 07.00 (Art. 235 risk-weight substitution), IRB guarantor to C 08.01 (Art. 161 parameter substitution). The sheet axis is now the union of the classes present and the classes receiving an inflow, so a guarantor class with no exposure of its own still gets a sheet, and a template with no native population of its own is still emitted when it is the only home for an inflow. - Amounts on existing returns are unchanged: no frozen golden number moved and the supervisory-validation register is unaffected, because no committed portfolio exercised the block. Firms reporting guaranteed exposures will see col 0070 populate where it previously read zero, col 0090 rise by the amount that was being deducted twice, and inflows appear on the guarantor's sheet.
- The inflow now lands on the published row decompositions, not only the Total row.
C 08.01 decomposes its total row twice over the same columns and both are live ERROR
rules —
boe_b0744on the balance-sheet axis andboe_b0745/v0338_mon the IRB treatment axis — and C 07.00 does the same on its risk-weight axis (v0312_m/boe_b0719). A Total-row-only inflow breached all of them by exactly the inflow. The inflow is now split by balance-sheet side, by post-substitution IRB treatment, and by risk-weight band, and landed on the matching rows. - Recorded decision R12 is superseded (it shipped in 0.3.18 as "C 08.02 deliberately
does not receive the cross-class CRM substitution inflow … no output change"). Four
live ERROR rules —
boe_b0752_8/boe_b0752_9/boe_b0814_07/boe_b0814_08— require{C 08.01 r0070, c0080/c0090} = sum({C 08.02, same col}), which an inflow-free C 08.02 cannot satisfy. R12's reasoning survives intact: per-grade attribution is unsound because the origin-basis ledger carries the obligor's grade and never the guarantor's. So the inflow lands on C 08.02's existing "Unassigned" residual row — an inflow whose guarantor grade the ledger does not carry is an exposure with no assigned grade — and no graded row carries any of it. - The post-model-adjustment disclosure carriers are rebased onto the substituted
basis. All four were left on the borrower basis after substitution re-blends the
RWA, so
{c0260} = sum({c0251..c0254})(boe_b0751/boe_b0763, live) diverged by exactly the Art. 235 relief on every guaranteed leg. Col 0251 is nowrwa_pre_adjustments x retained_share + guaranteed_portion x guarantor_rwwith each adjustment scaled by the retained share — which is also substantively right, since only the retained share of a mortgage-floor or unrecognised-exposure overlay survives into reported RWEA and the substituted part carries no model overlay. No RWA number moves; this is the disclosure decomposition only. - Coverage: the new portfolio is wired into the supervisory validation register for both regimes — the first in the estate with non-zero substitution cells. On it, failing rules fall CRR 6 → 3 and Basel 3.1 18 → 13.
- Recorded residuals, both banked in
validation_known_breaks.jsonwith written reasons: (1) the outflow subtotal counts every route in the block, but only the unfunded routes carry a destination class — the sealed ledger holds a guarantor class for guarantee and credit-derivative legs only — so C 07.00's funded limbs and C 08.01's col 0060 still produce an outflow with no matching inflow; closing it needs an issuer class sealed per collateral leg. (2) C 07.00 col 0200 sums raw EAD over the ORIGIN population and never reflects substitution while cols 0100→0110→0150 net it (v0308_m/v8726_m/boe_b0471/boe_b0556) — a genuine defect in our output, not a published-rule artifact. Note this is a build shortfall against a recorded decision, not a recorded decision with a measured cost: Phase 7 decision F3 cites "C 07.00 col 0200 basis" as the definition of the post-substitution basis (it keys Pillar 3 CR4 cols c-f and all CR5 rows on it), i.e. it assumed col 0200 was already post-substitution. The F4 slice that actually built C 07.00 made only the 0040/0110/0150 waterfall substitution-aware and never extended that treatment to col 0200 or the CCF buckets. It is not fixed here because col 0200 feeds the CCF-bucket identities and the C 08.01 cousins 0110/0260 sit on the same basis, so rebinding col 0200 alone risks making the return inconsistent elsewhere; it needs its own scoped change and golden regen. - The fixed PD scale on C 08.03 / C 08.05 and Pillar 3 CR6 / CR9 was a flat ladder of
invented bands; it is now the published hierarchical scale. Both templates carried
17 mutually exclusive PD buckets (
0.00 to < 0.03%,0.03 to < 0.05%, …,50.00 to < 100%) that matched the published row count but not a single published row label. The real scale — Regulation (EU) 2021/451 Annex I for C 08.03/08.05, PRA PS1/26 Annex I for OF 08.03/08.05, and PS1/26 Annex XXI for UKB CR6 / UK CR9 — is hierarchical: eight top-level bands partition[0, ∞)and four of them repeat their span as a finer sub-breakdown on the rows immediately below, so a parent row overlaps its children and equals their sum. Row 0080 is0.75 to < 1.75under the parent0.75 to < 2.50, not0.75 to < 1.00. Every exposure was therefore reported against a mislabelled PD range, and the four published parent/child sum rules per template (EBAv09753–v09756andv09757–v09760, BoEboe_b0767–boe_b0770andboe_b0773–boe_b0776) could never hold at any figure, because parent and children were disjoint by construction. - COREP now resolves its row axis per regime: 17 rows under CRR, and 18 under
Basel 3.1, where OF 08.03 / OF 08.05 split the
0.00 to < 0.10child at 0.05% (rows 0015 / 0025 replace CRR's row 0020). Pillar 3 does not follow — UKB CR6 and UK CR9 keep the coarser band in both regimes, so the two estates now carry their own tables (get_c08_03_pd_ranges/CR6_PD_RANGES). - The hierarchy needs two row keys, since an exposure sits in exactly one leaf band
while a parent spans several. A new
reporting/corep/pd_scale.pyderives both (c08_pd_range= leaf,c08_pd_parent= enclosing parent), keeping every row a single equality term forrow_termsand the drill-down predicates.CR6/CR9already keyed range predicates, so they needed only the corrected table. - Amounts are unchanged; this is a row-axis correction. A band that previously emitted one mislabelled row now emits the correctly-labelled leaf plus its parent, carrying the same figures. Anything aggregating across PD rows must now sum the leaf bands only — summing every emitted row double-counts.
- Known supervisory-validation breaks fall 28 → 14 with no new breaks: all 14
removed are the PD-scale sum rules the register had already diagnosed as "a
structural template defect, not a data gap". CRR
irb-classesFAIL 9 → 1, Basel 3.1 16 → 10, with two further rules now executable.
[0.3.20] - 2026-08-01¶
Added¶
- Supervisory reporting validation rules are now a first-class reference in the repo. The EBA and BoE both publish the machine-readable validation rules that supervisors run against a submitted return; until now nothing in this project referenced them, and
reporting/tieouts.pycarried just five hand-curated cross-template ties as the only in-house analogue.scripts/download_docs.pynow fetches both sources intodocs/assets/— the EBA validation-rule workbook (sheetv3.0(3.0.1)is the framework version matching current CRR reporting) and the BoE banking XBRL taxonomy validations v4.0.0 zip, from which the "Banking reporting" workbook holding the OF tables is extracted.DocEntrygained a generalextract_member/extract_asarchive mechanism to support this; extraction is idempotent, honours--force/--dry-run, and re-extracts from an already-downloaded archive without a network call. A newscripts/extract_validation_rules.pyfilters both workbooks to the credit-risk templates this project produces and emits a committed, pure-ASCII JSON extract todocs/reference/validation-rules/(the raw workbooks are gitignored, so the JSON is the durable artefact);--checkre-extracts and fails non-zero if the committed files would change. The extract carries 1,011 EBA rules (588 live) for C 02.00 / C 07.00.x / C 08.0x / C 09.0x / C 34.x and 820 BoE rules (808 live) for OF02 / OF07 / OF08 / OF09, preserving each rule's severity, type, table set, row/column/sheet scope and formula — plus, for 427 BoE rules, theeba_equivalentid that cross-references the two regimes. Both thecrrandbasel31skills gained areferences/reporting-validation-rules.mdcovering the formula grammar, severity semantics, scope expansion, and how a{r0010, c0200}reference maps onto our generated frames (column names are the COREP column codes;row_refcarries the row code). Note for consumers: a BoE rule's expression is not self-contained — the row/column/sheet binding lives in a separatescopefield, soboe_b0529reads{t: OF07.00.01.01} = 0and onlyscopereveals it means{r0140, c0220} = 0. - The supervisory validation rules are now enforced — in the test suite and before a submission.
src/rwa_calc/reporting/validations/evaluates the published EBA and BoE rules against the generated COREP/Pillar 3 bundles: one parser for both grammars, scope expansion (the EBA'srows/columnscolumns and the BoE's separatescope(...)field — in both sources a formula is meaningless without it, 161 live EBA formulas carry no row reference and 79 BoE expressions carry no binding at all), theArithmetic approachdistinction (Intervalis rounding-tolerant,Pointis exact), theIf value missingpolicy (treat as zerovsdo not run rule, which give materially different answers), and a liveness rule that counts a deactivated-then-reactivated EBA rule as enforced — 741 rules, not the 588 that filtering on status alone returns. Three codes:VAL001an Error-severity break (blocks the return),VAL002a Warning (explainable), andVAL003insufficient coverage. VAL003 exists because the checker originally failed open: an empty bundle returned no findings — because all 741 rules were unevaluated, not because anything passed — so a caller writing the obviousif not errors: submit()would have filed a return on which nothing was checked. It has two limbs, separately consumable via the finding'sfield_name:no_rule_executed(the estate was never reached — this blocks) andtemplate_not_covered(an emitted template had no rule run against it — reported, not blocking, because our C 34.x templates are stubs emitting 1 row × 1 column and blocking on them would reject correct CCR returns). The predicate lives once, onValidationReport.coverage_shortfall, which the VAL003 finding also reads, with a test asserting the finding fires exactly when the property reports a shortfall and carries the same reason. Production:ResultExporter.validate_submissionreturns a frozenSubmissionValidationResultwhoseis_submittableisnot blocking_breaks and was_checked— never inferred from an empty finding list — alongsideexport_validation_report(one row per rule outcome, with five run-level constants repeated on every row so the coverage caveat survives any sort or filter) andGET /api/validations, where a blocked submission is a 200 withis_submittable: false, never an error status. Test gate:tests/acceptance/reporting/test_supervisory_validations.pyruns six portfolio × regime combinations and ratchets against a committed register attests/expected_outputs/reporting/validation_known_breaks.json— no break outside the baseline, and no baseline entry that no longer breaks, so a fix must shrink it deliberately. The register records 75 known-broken rules and 4 known-uncovered templates, each with a written reason rather than a hash. Current position, honestly stated:is_submittableis false on both frameworks (CRR 18 blocking / 15 warnings of 257 rules executed; Basel 3.1 34 / 6 of 413), and the off-balance-sheet andccr_crrestates pass. Coverage is reported against its denominator everywhere, andNOT_EVALUATEDis never counted as a pass. CalculationConfig.firb_fixed_maturity— the CRR Art. 162(1) fixed F-IRB supervisory maturity election (P1.249). Art. 162(1) first sentence requires an institution that "has not received permission to use own LGDs and own conversion factors for exposures to corporates, institutions or central governments and central banks" to assign M = 0.5 years to repo-style exposures "and to all other exposures M of 2,5 years"; the second sentence lets the Art. 143 permission substitute the per-exposure Art. 162(2) derivation instead. Only the 0.5y repo-style limb was implemented — every other F-IRB row took the date-derived Art. 162(2) M clipped to [1, 5] with no route to the fixed 2.5y. SettingCalculationConfig.crr(..., firb_fixed_maturity=True)now pins F-IRB non-repo-style exposures (derivatives included — Art. 162(1) carves out repos and securities-or-commodities lending only) to 2.5 years, leaving the repo-style 0.5y limb, A-IRB rows, an expliciteffective_maturityinput and the Art. 162(3) one-day carve-out untouched. The default isFalse, so no existing number moves: the election is a firm-permission fact, and which limb applies is set by the firm's Art. 143 permission. For a GBP 1m F-IRB corporate at PD 1% / LGD 45% with a 2.0-year residual, the election raises RWA from 911,289.01 to 978,558.09 (+7.38%, the MA(2.5)/MA(2.0) ratio); it lowers RWA where the date-derived M exceeds 2.5 years. The election is inert under Basel 3.1 — PS1/26 Art. 162(1) reads "[Note: Provision left blank]" and Art. 162(2) puts both the Foundation and the Advanced IRB Approach on the Art. 162(2A) methods — enforced by the CRR-only pack Featurefirb_fixed_supervisory_maturity, with the 2.5y value homed on the cited pack scalarfirb_fixed_supervisory_maturity_years.
Changed¶
- The lease exposure-value input convention is now documented where a data provider will find it (P1.279). CRR Art. 166(4) (IRB) and Art. 134(7) (SA), and their PS1/26 counterparts Art. 166A(4) and Art. 134(7), all state that "the exposure value for leases shall be the discounted minimum lease payments" — the requirement is regime- and approach-invariant, and the same
drawn_amountcolumn feeds both. The engine cannot compute a discounted MLP even in principle (no minimum-lease-payment schedule and no discount-rate input exists anywhere), so the convention is documented on the LOAN schema page and indata/schemas.pyrather than implemented, and the residual-value leg is distinguished from the receivable leg. No marker field was added, deliberately: the only validation that would justify one needs inputs that do not exist, and an inert Boolean would read as a compliance attestation while carrying zero assurance — every other attestation on these schemas gates real behaviour. The zero-cost alternative (product_type = "finance_lease") is documented instead, with a warning thatproduct_typeis not inert: the classifier reads it for infrastructure, development-finance and mortgage signals, so a value such asMORTGAGE_FINANCE_LEASEwould trip mortgage detection. The Art. 201/213-215 third-party residual-value-guarantee limb of the same provisions remains unimplemented and is recorded as tracked separately.
Fixed¶
- An exposure secured by a mortgage on commercial immovable property is now reported in Article 112(1)(i), not under its counterparty's class. Such an exposure was taking the correct real-estate risk weight while being reported as a corporate: 10,000,000 of commercial-real-estate lending sat on the C 07.00 corporates sheet at 50%. The cause is that the SA dispatcher's
is_commercial_re_classpredicate routes into the Art. 126 / Art. 124H-124I commercial branch off the rawproperty_typecolumn as well as off the class string, so the risk weight followed the security while the class stayed with the counterparty — and the classifier could never correct it, because the real-estate loan-splitter, the only producer ofcommercial_mortgage, explicitly excludes income-producing property (has_income_cover) from the split and routes it down the whole-loan path instead. Both frameworks say the security is the classifying fact and both rank it the same way. PS1/26 Art. 112(2) Table A2 is explicit — "Where an exposure meets the criteria for more than one exposure class it shall be assigned to the exposure class that has the highest position in Table A2" — with real estate at row (7) (criteria: "Exposures for which a risk-weight treatment is set out in Articles 124 to 124L"), retail at (14) and corporates at (15). COREP Annex II gives the CRR twin: ¶62's prioritisation ranks "Exposures secured by mortgages on immovable property" 6th, above corporates and retail at 9th, and ¶58 notes class (i) is the one class where "a protection effect is intrinsically part of the definition of an exposure class". CRR Art. 124(1) carries the same logic in the operative text ("An exposure or any part of an exposure fully secured by mortgage on immovable property … except for any part of the exposure which is assigned to another exposure class"). The fix is a reporting-side overlay, not a classifier change, because Annex II ¶60 scopes the prioritisation to "the assignment of the Original exposure pre-conversion factor by exposure classes, without prejudice to the specific treatment (risk weight) that each specific exposure shall receive": the aggregator's_add_exposure_class_applied— which already re-maps defaulted and SME-managed-as-retail rows for exactly this reason — gained a commercial-real-estate limb that reuses the dispatcher's own predicate, so the reported class and the applied risk weight cannot drift apart again. It sits below the default limb and above the retail one, matching both rankings, and leaves high-risk / equity / covered-bond rows (which outrank real estate) and rows already in a real-estate class alone. The routingexposure_classis untouched, so approach selection, CRM and the risk-weight tables are unaffected and no RWA moves in either regime (verified across all five committed portfolios: CRR 145,511,467.29 and Basel 3.1 137,449,963.91 on the rich portfolio, unchanged to the cent). Three class→row maps that had no entry for the real-estate classes beyondretail_mortgageare filled in the same pass, each of which was silently dropping exposure rather than mis-placing it — acommercial_mortgageor splitter-emittedresidential_mortgageleg mapped to no row at all:C09_01_SA_CLASS_MAP(CRR C 09.01 row 0090, a pre-existing latent gap that this change makes reachable and that a unit test had pinned as the then-current behaviour), Pillar 3SA_DISCLOSURE_CLASSESrow 9 (CR4 / CR5), and both s0010 entries in the validation sheet map. Golden movement is a pure re-allocation, paired to the cent on every frame: a newcorep__c07_00__commercial_mortgagesheet; C 07.00 corporates −10,000,000 exposure / −5,000,000 RWEA (CRR) and −10,000,000 / −10,000,000 (B31); C 09.01 GB+TOTAL r0070 → r0090; C 02.00 r0130 → r0150 (CRR 13,380,950 → 8,380,950 and 140,000 → 5,140,000; B31 17,175,000 → 7,175,000 and 98,333 → 10,098,333); Pillar 3 CR4 r7 → r9 and CR5 r7 → r9 within the same risk-weight bucket. Under B31 the OF 09.01 "of which: regulatory CRE" sub-row 0092 populates for the first time (10,000,000), which is the follow-on the C 09.01 module docstring recorded as pending "if the classifier's IPRE-CRE scoping changes". The residential side was checked and needed no change:retail_mortgageis correctly in class (i) — the priority ranking puts the security above retail, and C 07.00 row 0040 is an "of which: … Residential property" of that class, which only parses if the class is the wider set — and no residential exposure anywhere in the estate is mis-classed, because the residential predicate has noproperty_typelimb and so never applies a real-estate risk weight to a non-real-estate class. - COREP C 08.01 / OF 08.01 and C 08.02 / OF 08.02 no longer double-count Article 199 collateral, which was reducing the reported exposure and driving "exposure after CRM substitution" NEGATIVE. Col 0060 ("other funded credit protection") summed the raw immovable-property / receivables / other-physical collateral carriers, which were already being reported in their proper home — the CRM-in-LGD-estimates block at cols 0190/0200/0210 — so the same amount appeared twice on one sheet: once correctly, and once as an exposure reduction. Observed live at 500,000 of real estate against a 300,000 residential mortgage, putting −500,000 in col 0060 and driving col 0090 ("exposure after CRM substitution effects pre-conversion factors") to −200,000 on a supervisory return. This is the same defect as the C 07.00 col 0080 one, and the same remedy — removal, not capping. Cols 0040-0060 sit under "CRM techniques with SUBSTITUTION effects on the exposure": the route where the protection provider's risk replaces the obligor's, i.e. an effect on PD. Annex II says so of col 0060 three times over — "Collateral that has an effect on the PD of the exposure shall be capped …"; "Where own estimates of LGD are not used, Article 232(1) CRR applies" (the Art. 200(1) list — third-party deposits, pledged life policies, instruments repurchased on request — treated as a guarantee); and decisively the routing sentence "Where an adjustment is made in the LGD, that amount shall be reported in column 170". PS1/26 is blunter: "Other funded credit protection that is treated as a guarantee in accordance with Article 232 … shall be included. Other funded credit protection that is not treated as a guarantee … shall be reported in 0172." Immovable property, receivables and other physical collateral are LGD mitigants under both IRB variants — recognised through Art. 230 where own LGD estimates are not used and through Art. 181(1)(e)-(f) where they are — never through substitution, so they never touch PD. Annex II routes them by article to a block whose heading excludes substitution effects outright ("CRM techniques that have an impact on LGD estimates as a result of the application of the substitution effect of CRM techniques shall not be included in these columns") and whose columns cite the paragraphs: 0190 REAL ESTATE "Article 199(2), (3) and (4)"; 0200 OTHER PHYSICAL COLLATERAL "Article 199(6) and (8)"; 0210 RECEIVABLES "Articles 199(5) and 229(2)". Col 0060 now reads the Art. 232 carriers only (
life_ins_collateral_value,third_party_deposit_value— the same pair C 07.00 reads), so the CRM waterfall closes at the full exposure rather than merely narrowing: col 0090 goes −200,000 → 300,000, and col 0190 still carries the whole 500,000, so nothing is lost. The Annex II cap on what legitimately remains in the block is retained and applied per leg to the block total (0040+0050+0060), shedding proportionally per the C 07.00 precedent — "shall be capped at the exposure value" (0040-0050) and "at the value of the original exposure pre conversion factors" (0060) — using the pre-conversion-factor basis throughout, because the block feeds the pre-CCF waterfall; it is inert on every committed portfolio now that the Art. 199 collateral is out, and kept because an Art. 232 deposit or a guarantee genuinely can exceed the exposure it covers. Col 0070 (the substitution outflow) stays outside the cap block: Annex II defines it as "the covered part of the original exposure pre-conversion factors", i.e. already a portion of the cap basis, and including it would make the cap bite on legs that are not over-collateralised at all, because a guaranteed leg that migrates class is counted by the waterfall in both col 0040 and col 0070 — the same double-subtractionfix-c07removed from C 07.00, currently inert here (no leg in any committed portfolio sits in both) and recorded as a separate follow-up. The col 0100 off-balance-sheet memo, which independently re-derives the same components, reads the same carriers so it cannot contradict col 0090. No committed golden in thecrr,b31,ccr_*oroffbs_*sets moves — none of those portfolios carries non-financial IRB collateral; the movement is in the newirb_classes_crrset (col 0060 −500,000 → 0, col 0090 −200,000 → 300,000). - COREP C 08.02 / OF 08.02 no longer reports slotting exposures against an obligor grade, C 08.01/02 emit their two missing Basel 3.1 columns, and C 08.07 / OF 08.07's coverage percentages and roll-out row axis now match the DPM. Four independent defects across the IRB templates, closing 18 published supervisory validation rules. (1) OF 08.02 was reporting the whole slotting book under an "Unassigned" grade row. PS1/26 Annex II §3.3.4 paragraph 77A is explicit — "Institutions shall complete this template in respect of exposures subject to the AIRB approach and the FIRB approach, but not in respect of exposures subject to the slotting approach" — and §3.3.2 paragraph 76 says the same structurally under both frameworks: "CR IRB 2 provides a breakdown of total exposures assigned to obligor grades or pools (exposures reported under row 0070 of CR IRB 1)", where row 0070 is the F-IRB/A-IRB union and slotting reports separately on row 0080 ("SPECIALISED LENDING SLOTTING APPROACH: TOTAL"). A slotting leg has no PD-derived obligor grade by construction (Art. 153(5)), so the retired code banded the entire 75,000,000 slotting book onto C 08.02's residual "Unassigned" row and broke the cross-template identity
{OF08.01 r0070} = sum({OF08.02})on every shared column. C 08.02 now reads the IRB non-slotting book, exactly as C 08.03/05 already did, so a slotting-only class emits no C 08.02 sheet at all. Closesboe_b0752_2/_9/_10/_29/_32/_34/_36andboe_b0814_01/_08/_09/_18/_20(12 Error-severity rules). The OF 08.01 side needed no change and got none: it was already right. (2) Col 0251 (RWEA pre-adjustments) read 0.0 on every slotting row.rwa_pre_adjustmentsis produced byapply_post_model_adjustments, which runs on the formula-IRB branch only, so it is null on slotting legs and a plainSumfilled it to 0.0 — leaving{c0260} = sum({c0251;0252;0253;0254})comparing a populated 52,500,000 total against empty components. It now coalesces per leg to the leg's own RWEA (the R10a expected-loss pattern, same cause): a value no-op on formula-IRB legs, and correct on slotting legs, which carry none of the three Art. 153(5A)/154(4A) adjustments cols 0252-0254 report — PS1/26 makes this explicit for col 0254 ("This column shall not be reported for sheets relating to the FIRB approach or the slotting approach"). Closesboe_b0751andboe_b0763. (3) Col 0104 was never emitted. "Institutions shall report the value reported in column 0090 after adjusting for the reduction in exposure due to the Financial Collateral Comprehensive Method reported in columns 0101-0103"; the published identity states it additively as{c0104} = sum({c0090;0101;0102})(col 0103 is an "of which" sub-item of 0102). It is now filled by a post-execute pass — not aFormulacell, because all three inputs are themselvesFormulacells and the executor refuses a formula that references a formula — reproducing col 0090 while the FCCM-under-slotting carriers stay unwired, with the subtraction written out so the cell stays truthful the day one is. Closesboe_b1040. (4) C 08.07 reported 0-100 percentages, and OF 08.07's row axis was misaligned by one class. Both instruction sets define cols 0030/0040/0050 as a quotient — "Institutions shall calculate this percentage by dividing (1) by (2)" (PS1/26 Annex II §3.3.10.2), "the exposure subject to the Standardised approach before CRM over the total exposure in that exposure class in column 0020" (COREP Annex II §3.3.6.2) — and every published rule bounds the cell at 1, not 100 ({c0030} <= 1,{c0050} <= 1,{c0030} + {c0040} + {c0050} = 1); the retired code multiplied by 100. Separately, OF 08.07 carried nine class rows 0180-0260 with a sovereign row, a Total on 0270 and a row 0280 the DPM does not have. PS1/26 Art. 147B(1) lists exactly eight roll-out classes — institutions; specialised lending; corporate purchased receivables; financial/large/other general corporates; QRRE; retail secured by residential property; retail purchased receivables; other retail — with no sovereign class (PS1/26 withdraws the IRB approach for sovereigns), and Annex II §3.3.10.2 puts them on rows 0180-0250, the Total on 0260 ("the sum of the values reported in rows 0180-0250 for each of columns 0060-0150") and the aggregate permanent-partial-use materiality percentage on 0270. The axis is rebuilt accordingly; row 0210 unions corporate and corporate-SME as the single Art. 147B(1)(d) class, rows 0200/0240 render structurally null (purchased receivables have no Art. 147(2) counterpart in this taxonomy), and — the number-changing part — the Total row is now the union of the eight roll-out classes rather than the whole population, so equity, other non-credit obligation assets and sovereigns correctly fall outside it. Closesv09769_mandv09771_m;v09796_m/boe_b0778now hold on every populated row andboe_b0779on cols 0010/0020/0060-0150 (see the two recorded residuals below). C 08.07 moved to its own modulereporting/corep/c08_07.py— it shares none of the C 08.01/02 value surface — which is the per-template splitc08.py's docstring already recorded as the honest long-term answer. Golden movement is confined tocorep__c08_*. - COREP C 07.00 / OF 07.00 no longer reports SA immovable-property collateral as a CRM substitution effect, and the Annex II CRM waterfall now foots. Column 0080 summed the raw, uncapped real-estate / receivables / other-physical collateral value into a column Annex II defines as "Other funded credit protection, Article 232 CRR". None of that collateral belongs there: CRR Art. 199 is headed "Additional eligibility for collateral under the IRB Approach" and admits immovable property, receivables and other physical collateral only for firms calculating under IRB, while Annex II names exactly three admissible items for cols 0050-0100 — financial collateral under the Financial Collateral Simple Method, other funded credit protection under Art. 232 (i.e. the Art. 200(1) list: third-party deposits, pledged life policies, instruments repurchased on request), and eligible unfunded credit protection. Under SA an immovable-property security drives the exposure class and its Art. 124-126 risk weight instead, so reporting it as a substitution effect double-counted the same benefit. On a realistic 60%-LTV mortgage it put 666,667 of property against a 400,000 exposure, drove col 0110 to -266,667, tripped the
max(0, ...)Art. 223(3) E floor on col 0150 and broke every identity downstream. Three changes land together: col 0080 now reads the Art. 232 carriers (life_ins_collateral_value,third_party_deposit_value); the whole 0050-0080 block is capped per exposure at its own gross-net-of-provisions value, as Annex II requires ("Collateral that has an effect on the exposure value ... shall be capped at the exposure value"), shedding any excess proportionally; and col 0090 is now the Annex II outflow subtotal 0050+0060+0070+0080 rather thanguaranteed_portionfor class-migrating rows only, with col 0110 correspondingly reduced to0040 - 0090 + 0100— it previously subtracted the components AND the outflow, removing every substitution twice. The E floor itself is unchanged: it is a correct Art. 223(3) transcription and simply stops firing. 33 published supervisory validation rules stop breaking, includingv10293_s/boe_b0667(no cell may be negative), the waterfall identitiesv0305_m/v0306_m/v0307_m/v0308_mand their BoE equivalentsboe_b0694/boe_b0697/boe_b0699/boe_b0471, and 18 of-which-row comparisons that failed only because the total they were measured against was negative. Only the C 07.00 goldens move, and only on the two collateralised sheets (corporate and retail_mortgage, both regimes). - COREP C 09.01 / OF 09.01's three "of which: SME" rows now populate, and both geographical-breakdown templates report a genuinely PRE-conversion original exposure in column 0010. Two independent defects in the geographical breakdown, both of which presented as missing disclosure rather than a wrong total, which is why neither had ever been caught by a tie-out. (1) The of-which SME rows were structurally dead. C 09.01's row keying is a reverse lookup over
C09_01_SA_CLASS_MAP, whose values are the parent row keys (corporate,retail,retail_mortgage); nothing ever mapped ontocorporate_sme/retail_sme/mortgage_sme, so rows 0075 / 0085 / 0095 short-circuited to a permanent all-null before any SME filter could run. Annex II and PS1/26 Annex II define all three identically — "Same definition as for row 0020 of [OF] CR SA template" — so each now keys its parent row's class union narrowed byc09_sme, which is C 07.00's own row-0020 SME ladder (sme_supporting_factor_eligible, falling back to the class-name test) reproduced verbatim: selecting on anything else could not tie out against the template the instruction points at. The narrowing term is basis-independent, so it survives the two-basis_either_predunion that keeps the 0020 defaulted memo on the original-class basis. B31 row 0095 isre_smerather thanmortgage_smeand is untouched — it narrows the real-estate class union by the rawis_smeborrower flag, mirroring C 07.00's RE memo rows (recorded R7). This closes seven published rules:v5773_q-v5776_qandboe_b0225-boe_b0227(the cross-template ties to C 07.00 row 0020 on the corporates sheet — 500,000 gross / 500,000 exposure value / 500,000 pre-SF RWEA / −119,050 SME adjustment / 380,950 post-SF RWEA), plus the Error-severityv0411_m({r0075} <= {r0070}), which was failing on column 0081 for a subtle reason worth recording: the SME supporting-factor adjustment is a "(-)"-labelled negative figure, so a null of-which row read as zero and0 >= −119,050is false. A missing disclosure was therefore breaking an inequality rule in the direction that looks like an over-statement. (2) Column 0010 reported a post-conversion figure. C 09.01 col 0010 is "Same definition as for column 0010 of CR SA template" and C 09.02 col 0010 is "Same definition as for column 0020 of CR IRB template" — both of which sum the sealed per-side gross carriers — but the geo templates boundead_gross, which isdrawn + CCF-adjusted undrawn. On an entirely drawn book the two coincide, which is why the rich portfolio never showed it; on the off-balance-sheet portfolio C 09.01 reported the corporates row as 12,300,000 against C 07.00's 22,500,000, the whole 10,200,000 of off-BS nominal having been silently converted away inside a column whose title is pre-conversion. Both templates now bindSafeSum(reporting_gross_on_bs, reporting_gross_off_bs, …), C 09.01 additionally carrying the counterparty-credit-risk term C 07.00's col 0010 carries (its population is C 07.00's, including the derivative netting sets, whose per-side carriers are null by design) and C 09.02 carrying none, exactly as C 08.01 col 0020 carries none. The two defaulted-subset twins that share the definition — C 09.01 col 0020 ("Original exposure pre-conversion factors for those exposures … classified as exposures in default") and C 09.02 col 0030 — follow the same ladder. A synthetic frame carrying no raw gross input at all keeps the retired single-column pick, sinceensure_gross_side_carrierswould give it structurally all-null carriers. This closesv5769_q/boe_b0222. Golden movement is confined tocorep__c09_01__{GB,TOTAL}in thecrr,b31andccr_b31sets (row 0075 populating); no C 09.02 number moves on any committed portfolio, because every IRB fixture leg is drawn. Recorded as still failing and not a C 09.x defect:v6051_m({r0170} = Σ rows excluding r0100) breaks only at columns 0010/0075/0080/0090, which belong to table C 09.01.a, not the C 09.01.b the rule is scoped to — the evaluator binds both DPM variants to our single union frame and has no column partition, so the rule is being evaluated on the other variant's columns. On C 09.01.b's own columns (0020, 0081) it passes, and C 09.01.a's counterpart rulev6050_m, which differs only by including r0100, passes on all 12 coordinates. The pair is in fact independent corroboration of this project's recorded two-basis C 09.01 design: r0100 belongs in the .a total because the primary columns key the applied class (defaulted is its own Art. 112(1)(j) class), and must be excluded from the .b total because those columns key the original class and would double-count it. - COREP C 07.00 / OF 07.00 columns 0160-0190 (the off-balance-sheet CCF breakdown) now populate, and report the correct quantity. Four defects stacked in one block, and because the outcome was empty cells rather than wrong numbers, nothing in the estate ever objected — the supervisory validation rules over those columns registered as unevaluable rather than failing, which is why this survived the template's whole life. (1) Dead carrier: the bucket expression read
ccf_applied, a column no pipeline run produces — it exists only indata/schemas.py,analysis/recon_registry.pyand the C 07.00 module itself, while every sibling template (corep/c08.py,pillar3/cr5.py,pillar3/cr6.py) reads the sealedccf. The read was inside anif "ccf_applied" in colsguard, so it silently did nothing instead of raising. The carrier is now a named ladder resolved via the repo's existingpick(),ccffirst, and a module logger warns when the buckets are about to publish null despite there being off-balance-sheet gross to break down. (2) Wrong quantity: the cells summed post-conversion EAD, but Annex II heads the block "breakdown of the fully adjusted exposure value of off-balance-sheet items by conversion factors" — i.e. pre-conversion. They now sumreporting_gross_off_bs, which is pre-conversion by construction (contingent →nominal_amount, facility_undrawn →undrawn_amount, loan → 0.0), is an unconditionalAGGREGATOR_EXITedge column, and — decisively — is what col 0010 already sums on the off-side and from which 0150 derives, so the buckets decompose 0150 by construction rather than by coincidence. (3) Narrowing never applied: the off-side restriction was gated onbs_type, a column the aggregator never seals (its own docstring says so), so on the ledger path on-balance rows were never excluded. A drawn loan carriesccf = 0.0, which is a real CRR bucket (Annex I low-risk → col 0160), so on-balance loans were landing in the 0% bucket; their gross there is 0.0 so no number moved, but the cell had quietly stopped being a statement about off-balance-sheet items. Both sites now route through one helper so they cannot drift apart again. (4)obs_productnever reached the CCF stage: neither hierarchy coercion projected it, so the Art. 111(1) product→risk_typefill inengine/ccf.pywas dead end-to-end and a documentary credit supplying onlyobs_productfell to the 50% medium-risk default instead of 20%. It is now declared oncontracts/edges.py::_hierarchy_resolved_columns()(required=Falsewith anull_meaning, propagating to the hierarchy/classifier/CRM exits in one place) and projected throughunify.pyandfacility_undrawn.py; it is deliberately not on_calc_output_common_columns(), so it is still stripped at the calculator exit as the input-domain column it is. No existing golden moves — the main and CCR reporting portfolios are entirely drawn loans, and the empty-subset contract keeps their cells null rather than flipping to 0.0; the CCR portfolio's drawn loan atccf 0.0passing unchanged is itself the proof the narrowing works. Newoffbs_{crr,b31}goldens (8 exposures across every CCF band in both regimes) pin the behaviour: the buckets sum to 18,500,000 = c0150 on row 0080 in both regimes (closing the live Error rulev6364_m), row 0070 is null in every bucket, and the SA exposure-value waterfall closes exactly on generated cells —boe_b0471at22.5 − 0.9(3.0) − 0.8(1.5) − 0.6(6.0) − 0.5(6.0) = 12.0munder Basel 3.1, and the CRR analogue at22.5 − 3.0 − 1.2 − 6.0 = 12.3m, whose (1−CCF) coefficients differ because col 0160 is the 0% unconditionally-cancellable bucket under CRR and the 10% bucket under Basel 3.1. The two coefficient sets are pinned separately so they cannot be conflated. Converging the seven synthetic test/fixture files that still supplyccf_appliedontoccfis recorded as a follow-up. - Non-finite (NaN/±inf) values in raw input tables are now nulled at the pipeline entry with a new DQ011 error, instead of poisoning
rwa_final— and, under Basel 3.1, blanking the ENTIRE portfolio through the output floor. A NaN in any float input column (a guaranteeamount_covered/percentage_covered, a loandrawn_amount/effective_maturity, a ratingpd, ...) survives every downstream arithmetic step — Polars float.sum()propagates NaN — so it flowed through the CRM guarantee split intoead_final/risk_weight/rwa_finalon the affected__G_/__REMlegs, surfacing only at the aggregator as an AGG001 error with the rows excluded from portfolio totals. Because the poisoned legs report under the guarantor's approach, the failure presented as "IRB/RIRB exposures guaranteed by a standardised counterparty" when the defect was the input data all along; onlypd/lgdwere previously scrubbed (to their regulatory floors, AGG002), and thelgdscrub still leftrwa_finalNaN. Two independent layers close it. (1) Pipeline-entry gate (contracts/validation.py::scrub_non_finite_values, called byrun_with_dataso both the file-loader and in-memory entry paths are covered): every float column of everyRAW_TABLE_EDGESframe is scanned in one aggregate pass per table; non-finite values are replaced with null — the documented degradation value, handled by downstream null semantics instead of blanking totals — and one aggregate DQ011 error per affected (table, column) records the count and up to five row references. A clean bundle passes through untouched. (2) Output-floor guard (engine/aggregator/_floor.py): the Art. 92 para 2A portfolio sums previously propagated one row's NaN into U-TREA/S-TREA and, via the pro-rata shortfall, into EVERY floor-eligible row's post-floorrwa_final(observed live: one NaN guarantee input blanked a whole B31 portfolio). Rows with a non-finite pre-floor RWA are now excluded from the floor computation — no share, no total contribution, their own (AGG001-flagged) value preserved — and a non-finitesa_rwafollows the existing null convention (counts as 0 in S-TREA). The AGG001/AGG002 aggregator net is unchanged and still catches anything minted mid-pipeline. No clean-data number moves in either regime; a NaN-carrying book previously reporting AGG001 exclusions (or a NaN B31 portfolio total) now reports finite totals plus DQ011s naming the offending source columns. The nested CCR/SFT composite bundles are outside the entry gate (the AGG001 net still covers them); extending the gate there is recorded as a follow-up. - CRR central-government and central-bank IRB exposures are no longer floored at 0.03% (P1.277). CRR Art. 160(1) is exhaustive about its scope — "The PD of an exposure to a corporate or an institution shall be at least 0,03 %" — and retail is floored by the separate Art. 163(1); neither article reaches central governments or central banks, so the CRR
pd_floorsbundle'ssovereign: 0.03%was a conservative over-statement. It is now0. Because every CRR floor was previously identical,_pd_floor_expressioncollapsed to a single scalar and never executed its per-class ladder under CRR; making the bundle non-uniform activates that ladder, which moves two surfaces. Direct exposures: a CGCB F-IRB row with a modelled PD below 3bp keeps its PD — on EAD 1,000,000 at PD 0.01%, LGD 45%, M 2.5y the risk weight falls from 15.31% to 7.98% (RWA 153,101.81 → 79,841.93). Guaranteed exposures: the three guarantor-class call sites inengine/irb/guarantee.py(the Art. 161(3)/160(4) substitution floor, its double-default variant and the expected-loss mirror) begin routing by the guarantor's class under CRR, so a central-government guarantor's substituted PD is likewise unfloored — correct under Art. 161(3), which benchmarks the covered portion against "a comparable, direct exposure to the guarantor", itself unfloored. Basel 3.1 is unchanged (PS1/26 Art. 160(1) does floor sovereigns, at 0.05%), corporates, institutions and all retail classes keep the 0.03% floor, and a row whose guarantor class is unknown still takes the conservative 0.03%. No reporting golden moves: the floor only binds below 3bp, and the two fixture rows in that range are a corporate (whose Art. 160(1) floor is unchanged) and thep2_36unit-fixture sovereign — the repo's only central-government IRB row with a sub-3bp PD, whose RWA does fall from 153,101.81 to 79,841.93 but which has no golden-output entry. That row is now pinned absolutely so the unfloored sovereign arm cannot regress unnoticed. Residual conservative gap recorded: aninternational_organisationIRB row still takes the 0.03% corporate arm although Art. 147(3)(c) assigns 0%-risk-weight IOs to the central-government class. - The Art. 161(5)(b) LGD* floor now divides by the Art. 230(1) exposure basis instead of the post-CCF EAD (P1.248, follow-up). PS1/26 Art. 161(5)(b) requires LGD* to be "calculated using the Foundation Collateral Method in accordance with the Credit Risk Mitigation (CRR) Part", and Art. 230(1) states the denominator as
E × (1 + H_E). The blend divided byead_gross— the post-CCF value — which over-weights the secured share on every off-balance-sheet row, and because every substituted LGD_S (0/10/10/15%) sits below every LGD_U (25/30/50%), over-weighting the secured share lowers the floor. A £1,000,000 undrawn commitment at CCF 40% with £200,000 of eligible cash collateral was floored at 12.5% where Art. 230(1) requires 20% (RWA 134,683.78 instead of 215,494.05 — 37.5% under-capital on that row), and the error scales with1 − CCF. The pre-fix expression was not even a convex combination: a fully collateralised off-balance-sheet row returned a 37.5% "floor", above the 25% LGD_U ceiling. The denominator is nowead_for_crm × (1 + exposure_volatility_haircut), sourced from a single sharedengine/crm/expressions.py::lgd_star_exposure_basis_exprthat the F-IRBlgd_star_exprand the SA Art. 228(1) gross-up also consume. Retail off-balance-sheet blended floors move the same way and in the same direction; no golden moves, because the golden portfolio contains no A-IRB row with recognised collateral. - CRR IRB now assigns non-named multilateral development banks to the INSTITUTIONS exposure class (P1.276). CRR Art. 147(3)(b) admits only the Art. 117(2) named (0% risk weight) MDBs to the central-government IRB class; Art. 147(4)(c) assigns "exposures to multilateral development banks which are not assigned a 0 % risk weight under Article 117" to the institutions class. The classifier previously left every MDB's
exposure_class_irbon its Standardised label (mdb) —sync_irb_exposure_classoverwrote the mapped IRB class — so a non-named MDB could only match an IRB model permission recorded under anmdbexposure class, never the institution-class permission Art. 147(4)(c) actually engages. The reroute is gated on the new citedcrr_non_named_mdb_institution_irb_classpack Feature and is CRR-only: PS1/26 Art. 147(3)(f) assigns ALL MDBs to the quasi-sovereign class with no 0%-risk-weight qualifier, so Basel 3.1 is unchanged, as aremdb_namedrows in both regimes. The change is parameter-neutral given the approach — the IRB correlation ladder is identical across MDB / institution / central-government, the FI 1.25× scalar is driven by counterparty flags rather than class, the PD floor is selected from the (unchanged) Standardisedexposure_class, and no COREP or Pillar 3 class row readsexposure_class_irb— but it is approach-changing, and therefore RWA-changing, for any firm whose non-named-MDB IRB permission is recorded undercentral_govt_central_bankormdbrather thaninstitution, in both directions. No golden moves, because the only non-named MDB fixture carries a CQS rating but nointernal_pdand nomodel_permissionsrow, so it cannot pass the IRB gate. - Third-country PSEs without an HM Treasury supervisory-equivalence determination now take the CRR Art. 116(5) flat 100% risk weight, in BOTH regimes (P1.252). Art. 116(5): "When competent authorities of a third country jurisdiction, which apply supervisory and regulatory arrangements at least equivalent to those applied in the United Kingdom, treat exposures to public sector entities in accordance with paragraph 1 or 2, institutions may risk weight exposures to such public sector entities in the same manner. Otherwise the institutions shall apply a risk weight of 100 %." The requirement is live under Basel 3.1 too: PS1/26 Art. 116 scopes paragraphs 1-3 to "UK public sector entities" and admits third-country PSEs only via Art. 116(3A) "[f]or the purpose of Article 116(5) of CRR", recording Art. 116(5) itself as "[Note: Provision not in PRA Rulebook]" because the Treasury equivalence power stays in CRR. No jurisdiction predicate existed anywhere in the SA override chains, so a German unrated PSE backed by a CQS-1 sovereign was weighted at 20% under both regimes (RWA 20,000,000 on a GBP 100m exposure instead of 100,000,000). A new nullable counterparty input
is_equivalent_jurisdictionnow gates all three PSE treatments — the Art. 116(1) Table 2 sovereign-derived lookup, the Art. 116(2) Table 2A own-rating weight and the Art. 116(3) short-term 20% — viaengine/sa/jurisdiction.py::pse_jurisdiction_not_permitted_expr, with the 100% homed on the cited pack scalarpse_non_equivalent_jurisdiction_rw. NULL MEANS NOT EQUIVALENT: equivalence is an affirmative Treasury determination, so an absent assertion cannot manufacture one and Art. 116(5)'s own residual governs; a nullcp_country_codelikewise cannot prove UK-ness. UK PSEs never consult the flag, so an entirely UK book is unaffected and needs no data migration. The gate is regime-invariant — no pack Feature and no regime branch. A second, distinct gate implements the same articles' UK-only scoping of the Art. 116(3) short-term preferential:pse_short_term_eligible_exprrestricts the 20% to UK PSEs, so an equivalent third-country PSE with an original maturity of three months or less now takes its Table 2 / Table 2A weight (50% at CQS 2) rather than 20%. PS1/26 Art. 116(3A) remaps "UK public sector entities" for paragraphs 1 and 2 only — paragraph 3 keeps its literal UK scope — and CRR Art. 116(5) admits third-country PSEs only "in accordance with paragraph 1 or 2"; the conservative reading is applied in both regimes to avoid an anti-conservative divergence on the same population. Firms holding third-country PSEs must populateis_equivalent_jurisdiction=Truewhere a Treasury determination exists, or those exposures will be weighted at 100%. - Basel 3.1 A-IRB corporate and institution exposures that are only PARTIALLY secured are now floored on the PRA PS1/26 Art. 161(5)(b) LGD* blend instead of the flat 25% unsecured floor (P1.248). Art. 161(5) splits the A-IRB LGD input floor in two: point (a) is "a flat 25% floor value for unsecured exposures to corporates and for exposures where the institution chooses not to take into account funded credit protection", while point (b) covers "secured and partially secured exposures where the institution chooses to take into account funded credit protection" and sets the floor to the Art. 230 / 231
LGD*value computed with 25% substituted for LGDU and LGDS of 0% (financial collateral), 10% (receivables), 10% (immovable property) and 15% (other physical collateral). The EAD-weighted blend was implemented but gated toretail_other/retail_qrreonly, so every corporate and institution A-IRB row fell through to the flat unsecured floor no matter how much collateral the model recognised — a GBP 10m corporate loan with GBP 4m of recognised cash collateral was floored at 25% (blend: 0.6 x 25% + 0.4 x 0% = 15%), giving RWA 6,734,189.18 instead of 4,040,513.51 at PD 2% / M 3y. The blend now applies to every exposure class exceptretail_mortgage, which keeps its flat 5% floor under Art. 164(4)(a); LGDU routes 50% QRRE / 30% retail other / 25% otherwise (Art. 161(5)(b)(iii)). Rows with no recognised protection are unaffected — they stay on the Art. 161(5)(a) flat floor — as are F-IRB rows (supervisory LGDs are not floored) and CRR, which has no A-IRB LGD floors. - Basel 3.1 F-IRB and Slotting commitments flagged
is_uk_residential_mortgage_commitmentnow receive the PRA PS1/26 Art. 111(1) Table A1 Row 4(b) 50% conversion factor (P1.251). Art. 166C(1) sets the F-IRB / Slotting off-balance-sheet exposure value using "the conversion factor that would be applicable to the off-balance sheet item under the Standardised Approach, as set out in ... Article 111", which includes the PRA-specific Row 4(b) 50% factor for "UK residential mortgage commitments that are not subject to a conversion factor of 10% or 100%". The Row 4(b) override was written to the SA conversion-factor carrier only, so a B31 F-IRB row kept its generic bucket rate (Row 5 "other commitments" 40%, or Row 6 20%) — a GBP 1m fully-undrawnOCcommitment took EAD 400,000 instead of 500,000 (RWA 369,267.21 instead of 461,584.01 at PD 1% / LGD 45% / M 2.5y, a 20% EAD understatement). The override now lands on both carriers (engine/ccf.py::CCFCalculator._apply_uk_residential_mortgage_ccf, mirroring_apply_purchased_receivable_ccf), with the Row 4(b) carve-out tested per carrier so Row 7 UCC (10%) and Row 1/2 (100%) rows stay untouched. SA and Slotting numbers are unchanged (they already read the patched SA carrier), and the flag remains inert under CRR, where F-IRB commitments take the Art. 166(8)(d) 75% supervisory factor. - CRR non-named MDB guarantors now price from the institution risk-weight tables instead of the Basel 3.1 MDB Table 2B (P1.253). CRR Art. 117(1): "Exposures to multilateral development banks that are not referred to in paragraph 2 shall be treated in the same manner as exposures to institutions. The preferential treatment for short-term exposures as specified in Articles 119(2), 120(2) and 121(3) shall not be applied." There is no MDB risk-weight table in CRR — the dedicated Table 2B (CQS 2 = 30%, unrated = 50%) is PRA PS1/26 Art. 117(1)(a)/(b) only. The shared guarantor expression (
engine/sa/guarantor_rw.py::build_guarantor_rw_expr, compiled by both the SA and the IRB guarantee-substitution paths) applied Table 2B under both regimes, so a CRR MDB guarantor was anti-conservative at two CQS bands: CQS 2 30% → 50% (Art. 120 Table 3) and unrated 50% → 100% (Art. 121 unrated institution fallback, conservative absent a guarantor-sovereign CQS join). On a GBP 1m exposure fully guaranteed by a CQS 2 non-named MDB that is 200,000 of RWA relief withdrawn (300,000 → 500,000). CQS 1 / 3 / 4 / 5 / 6 are unmoved (the two tables coincide there), the Art. 117(2) named-MDB 0% carve-out is untouched in both regimes, the Art. 120(2) short-term Table 4 is deliberately not consulted for MDBs, and Basel 3.1 keeps Table 2B. The direct (non-guarantor) CRR MDB path insa/risk_weights.pywas already correct; this aligns the guarantor path with it. - COREP C 02.00 / OF 02.00 rows 0070-0211 now route SA RWEA by the exposure classes the pipeline actually seals, closing five Error-severity EBA validation rules.
C02_00_SA_CLASS_MAPwas keyed on a vocabulary no run has ever produced —central_government,regional_government,public_sector_entity,multilateral_development_bank,secured_by_property,higher_risk,other_items,retail— against a sealedreporting_class_originthat carriesExposureClassmembers (central_govt_central_bank,rgla,pse,mdb,retail_mortgage,high_risk,other, ...). A key that never matches does not raise: its row simply zero-fills, so RWEA in an unmapped class dropped out of the 0070-0211 breakdown entirely while the parent row 0060 (computed independently as the SA approach total) still carried it. Two classes were affected —corporate_smeandother— and a third,retail_mortgage, was mapped to row 0140 Retail when Art. 112(1)(i) puts it in row 0150. Rows 0070-0211 are "See CR SA template" (COREP Annex II §1.3.1; PS1/26 Annex II §1.3.1), i.e. each is a published identity against the C 07.00 sheet for the same Art. 112(1) letter, so the map's groupings are now the C 07.00 sheet groupings (validations/scope.py::_C07_SHEETS/_OF07_SHEETS) letter for letter: 0130 (g) takescorporate+corporate_sme+specialised_lending, 0140 (h) takesretail_other+retail_qrreonly, and 0150 (i) takesretail_mortgageplus the Art. 124A/124H loan-splitter legs — the same union as C 09.01 row 0090. Rows 0190 (n) and 0200 (o) stay unmapped, matching the empty sheet keys for s0014/s0015. Five live Error rules go from FAIL to PASS:v0207_m(r0060 = Σ r0070..r0211, previously short by 400,950 on the rich CRR portfolio),v4240_i(r0130 = C 07.00 s0008: 13,000,000 → 13,380,950),v4241_i(r0140 = s0009: 327,500 → 187,500),v3334_i(r0150 = s0010: 0 → 140,000) andv3338_i(r0211 = s0017: 0 → 20,000); the Basel 3.1 frame moves the same four rows across all three columns. Only the class rows move — the 0010/0050/0060/0220 totals and everyreporting/tieouts.pytie are computed independently of this map and are unchanged. A new contract test ties each class row to its CR SA sheet code in both regimes and rejects any map key that is not anExposureClassmember, so this class of defect cannot recur silently. Recorded separately as NOT fixed here: the C 02.00 row AXIS itself still diverges from both Annexes (our r0040 is "TOTAL OWN FUNDS REQUIREMENTS", a row neither Annex defines, where both put credit RWEA; the IRB block is shifted; r0020/r0030 are absent). None of the five rules above depends on it.
[0.3.19] - 2026-07-22¶
Fixed¶
- COREP C 08.03 cols 0010/0020 (on-/off-balance-sheet gross exposure) now include the undrawn commitment legs they were silently dropping, so the reported exposure value (col 0040) no longer vastly exceeds on-BS + off-BS gross with no inflow to explain the gap. The fix seals per-side gross carriers at the aggregator and repoints every gross cell across the reporting estate to them, replacing the per-template balance-sheet ladders; no golden movement in either regime. The on-/off-balance-sheet discriminator mapped
loan→ on andcontingent/facility→ off, leaving the syntheticfacility_undrawn(undrawn commitment headroom) leg null on both sides — so it was excluded from both gross columns while its exposure value stayed in col 0040 (reproduced on a mixed band: loan 5,000 + contingent 2,000 + facility_undrawn 4,000 with EAD 3,000 → 0010=5,000, 0020=2,000, 0040=9,000). Two new sealed aggregator-exit columns replace the null-prone discriminator for gross reporting:reporting_gross_on_bs(loan / contingent /facility_undrawn/ legacyfacilitylegs → drawn + accrued interest, each clipped at 0; null only when both components are null; null for CCR / settlement legs) andreporting_gross_off_bs(contingent → nominal;facility_undrawn→ undrawn headroom counted exactly once; loan → 0.0; legacyfacilityalias →max(nominal, undrawn); null for CCR / settlement legs), both declared on theAGGREGATOR_EXITedge (contracts/edges.py; CRR Art. 111). Every gross cell repoints to the side carriers: C 07.00 col 0010 (now sources contingent nominal and accrued interest as on-BS original exposure per Art. 166), C 08.01/02 cols 0020/0030, C 08.03 cols 0010/0020 (the reported defect) and its col 0030 CCF weight, C 08.06 cols 0010/0030 (undrawn headroom no longer double-counted), and Pillar 3 CR4 a/b, CR5 ba/bb, CR6 b/c, CR10 a/b. The CR4 col b / CR5 col bb off-BS gross double-count is corrected:facility_undrawnrows carry the headroom in both the nominal and undrawn carriers (aliased), so the retirednominal + undrawnsum counted it twice — the side carrier counts it once. A newreporting/pillar3/irb_scope.pynarrows the CR6/CR10 population locally — counterparty-credit-risk and settlement legs are excluded (disclosed in the CCR-series per EU 2021/637 Annex XXII/XXIII, the IRB mirror of the CR4/CR5sa_scopedecision) and thefacility_undrawncommitment is reclassified off-balance-sheet — closing the recorded R3 follow-up. The obsolete whole-bucket fallbacks are deleted (C 08.03 cols 0010/0020; C 08.06 col 0030): the side carriers are row-level and null outside their side, so a band with no off-BS rows sums 0.0 naturally. The template-local balance-sheet ladders (c07_bs,c08_bs,filter_off_bs) mapfacility_undrawn→ off for the EAD "of which" side splits. The sealedreporting_on_balance_sheetcontract is untouched (it belongs to consumers that make their own scope decisions); COREP C 08.x keeps its CCR legs in the population (their exposure value / RWEA stay in cols 0040/0090, and the null side carriers keep them out of the 0010/0020 gross columns per Annex II). No golden movements in either regime. Ref: CRR Art. 111 (off-balance-sheet items / CCFs); Art. 166 (IRB exposure value, including accrued interest); Reg (EU) 2021/451 Annex II (C 07.00, C 08.01/02/03/06); EU 2021/637 Annex XXII/XXIII (Pillar 3 CR6/CR10 credit-risk scope); PRA PS1/26 Art. 111.
[0.3.18] - 2026-07-22¶
Changed¶
- Pillar 3 CCR1, CCR2, CCR3 and CCR8 converted from imperative to declarative
TemplateSpecs and instrumented for report-cell lineage — the FINAL declarative conversion of the reporting estate (R27c). Pure refactor: thepillar3__ccr1/ccr3/ccr8goldens are byte-identical in both regimes, and the R5 CCR8 CCP-scoping unit suite passes untouched. A newreporting/pillar3/ccr.pymodule hosts the fourgenerate_ccrNbodies as specs run through the onecellspec.executeexecutor, plus each template'sccrN_plans/ccrN_framesfor lineage; the fourPillar3Generator._generate_ccrNdispatch routers keep their signatures (the R5 unit suite calls_generate_ccr8/_generate_ccr1directly), and the Pillar-3 CCR population helper (_collect_ccr_rows, theinclude_sftflag) moves toccr.py— kept LOCAL rather than sharingcorep.c34.collect_ccr_rowsbecause it gates only onexposure_referenceand returns an empty frame (not None) for an empty selection, the exact contract the retired_ccr_rowscarried. CCR1 sums the SA-CCR EAD (col a) over theccr__netting-set population (FCCM SFTs excluded) and the non-QCCP default-risk RWEA (col b) over the derivedccr1_default_riskcomplement (~((cp_entity_type == ccp) & cp_is_qccp.fill_null(True)); CRR Art. 107(2)(a)). CCR2 reads the portfoliocva_rwaroll-up as a broadcast per-row constant (FirstNonNull, the C 34.04 idiom), presence-gated exactly like the imperative generator (None under CRR — no CVA charge). CCR3 allocates the SA-CCR EAD across the CR5 risk-weight bands via the module-derivedccr3_bandlabel (a first-match ±0.005 assignment reproducing_ccr3_band_eads; the bands do not overlap, so first-match equals the imperative per-band filter), with an "Other" catch-all and a Total row. CCR8 keys theinclude_sft=TrueCCP population (a CCP-faced SFT IS a CCP exposure, CRR Art. 301(1)(b)) restricted tocp_entity_type == ccp, split by the derivedccr8_qccpflag into QCCP / non-QCCP / Total — the R5 CCP restriction preserved exactly (a bilateral counterparty reaches neither row). CCR1/CCR3/CCR8 tie out against the CCR derivatives oracle in the lineage harness (the rich portfolio has no derivatives); CCR2 is pinned by a seeded lineage unit pin (no producing CVA fixture). The now-unused imperative helpers (_ccr_rows,_ccr_rwa,_ccr8_sum,_ccr3_band_eads,_first_non_null,_col_sum,_make_row,_build_df) are removed, along with the pre-existing dead_safe_sum/_null_row/_ead_weighted_avg/_filter_by_approachextraction leftovers — thePillar3Generatoris now a pure dispatch-and-export shell. With this item every declarative template is instrumented; the ONLY uninstrumented template left is C 02.00 (a pre-pass kernel-plus-thin-shell hybrid with noTemplateSpecto read). Ref: CRR Art. 439 (Part 8 CCR disclosures), Art. 274(2), Art. 306(1), Art. 444(e); PRA PS1/26 App.1 CVA Part Ch.4.2–4.4, Disclosure Art. 456. - COREP C 34.02 (SA-CCR EAD per netting set) converted from imperative to declarative
TemplateSpecand instrumented for report-cell lineage — the first MULTI-SHEET counterparty-credit-risk template with drill-down (R27b). Pure refactor: thec34_02__NS_*goldens are byte-identical in both regimes.generate_c34_02now builds one sheet per netting set through the onecellspec.executeexecutor, keyed on thenetting_set_idstripped from theccr__reference prefix (the C 08.04 multi-sheet pattern); each sheet's plan frame is that netting set's slice of the pre-filtered SA-CCR population (FCCM SFTs excluded via the sharedcollect_ccr_rows), and the single row 0010 sums that set'sead_final(CRR Art. 274(2) EAD = alpha * (RC + PFE) per netting set — theSumverb'sfill_null(0.0)matches the retiredgroup_by(...).agg(...)exactly). TheCOREPGenerator._generate_c34_02dispatch router keeps its signature and empty-dict semantics;c34_02_plans/c34_02_framesexpose the per-netting-set execution plans for lineage, tied out on theNS_CCR_QCCPnetting set the CCR derivatives oracle produces. The now-unusedc34_frameimperative frame-builder is removed. With C 34.02 declarative, the imperative reporting residual shrinks to C 02.00 (a kernel-plus-thin-shell hybrid) and the Pillar 3 CCR1–8 family (R27c). - COREP C 34.01, C 34.04 and C 34.08 converted from imperative to declarative
TemplateSpecs and instrumented for report-cell lineage — the first counterparty-credit-risk templates with drill-down (R27a). Pure refactor: the C 34.01 / C 34.08 goldens are byte-identical, and the CVA C 34.04 (no producing golden fixture) is byte-identical to the CVA-A1 unit estate. A newreporting/corep/c34.pymodule hosts the threegenerate_c34_0xbodies as specs run through the onecellspec.executeexecutor, plus each template'sc34_0x_plans/c34_0x_framesfor lineage; theCOREPGenerator._generate_c34_0xdispatch routers keep their signatures (the R5 C 34.08 unit suite calls_generate_c34_08directly), and the shared CCR population helpers (collect_ccr_rows/collect_default_fund) move toc34.pyas the CCR home — C 34.02 (per netting set, still imperative until R27b) readscollect_ccr_rowsback. C 34.01 pre-filters its plan frame to the SA-CCR netting-set population (the CR8 pattern —ccr__-prefixed rows, FCCM SFTs excluded), summing EAD (col 0010) and RWEA (col 0020). C 34.08 keys three heterogeneous populations on one full-ledger plan: rows 0010/0020 partition the CCP subset (cp_entity_type == ccp) by the derivedc34_qccpflag (cp_is_qccp.fill_null(True)— the R5 CCP restriction is preserved exactly, so a bilateral OTC counterparty reaches neither row), while row 0030 keys theCCR_DEFAULT_FUNDrisk type. C 34.04 (Basel 3.1 only, gated on a positivecva_rwa) reads the portfolio BA-CVA roll-up as a broadcast per-row constant (FirstNonNull, the OV1 row-26 idiom) — a row-backed cell that does not reconcile to a signed total. C 34.01 / C 34.08 tie out against a separate CCR derivatives source in the lineage harness (the rich portfolio has no derivatives); C 34.04 is pinned by a seeded lineage unit pin. Only C 02.00 (a kernel hybrid), C 34.02 and the Pillar 3 CCR1–8 family remain imperative. - Report-cell lineage (drill-down) now covers Pillar 3 CR9, CR9.1 and CR10 — the FINAL instrumentation item, closing the declarative template estate (R26). Pure refactor: no golden or reported-figure change.
cr9.pyandcr10.pyeach gained a<t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan]builder (pluscr9_1_plans) that the thingenerate_<t>iterates, so a cell's spec and its reported value key identically, and a_Providerwas registered inLINEAGE_PLANS. CR9 / CR9.1 key the compoundf"{approach} - {leaf class}"sheet on the obligor basis (the CR6 basis — Annex XXII bars substitution effects) and are Basel 3.1 only: like CMS1/CMS2 under CRR,cr9_plansyields{}on a CRR run, so a CRR lineage request degrades to a clean no-lineage. Their value cells are counts, weighted averages, arithmetic means and intra-row formulas — noSumcell, so the tie-out sweep reconciles a whole sheet by predicate-match count rather than a signed total (CR9 forces defaulted legs into the 100% band viacr9_alloc_pdand drops empty PD bands post-execute). CR9.1 is empty on the real portfolio (the engine produces neitherecai_pd_mappingnorexternal_rating_equivalent), so it carries a seeded unit pin instead of an acceptance tie-out. CR10 keys per subtemplate (sl_type, plus the CRRequitysheet); its fixed colcrisk weight is unbound in the spec and stamped post-execute, so the drill-down reports it as the template's empty policy and reads the display weight from the reported frame (the C 08.06 unbound-0070 precedent; the equity sheet's colbis unbound the same way), while every other cell (a/b/d/e/f) reconciles as aSum/SafeSum. Tie-out cases added forcr9(two B31 compound sheets),cr10(project_financeandequity, CRR); unit pins added for the instrumentation shape, the CR10 unbound col c, the CR9 compound sheet key and the seeded CR9.1 parity. With this item every declarative template is instrumented — the only remaining gaps are the imperative C 02.00 (a pre-pass kernel hybrid) and the imperative C 34.x / CCR1–8 counterparty-credit-risk family, which expose noTemplateSpecto read. - Report-cell lineage (drill-down) now covers the two COREP C 09 geographical-breakdown templates and Pillar 3 CR6 — C 09.01 (SA exposures by country), C 09.02 (IRB exposures by country) and CR6 (IRB by exposure class and PD range, per obligor class) (R25). Pure refactor: no golden or reported-figure change. Each generator was split into a
<t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan]builder plus a thingenerate_<t>that executes each plan, and a_Providerwas registered inLINEAGE_PLANS. The two C 09 templates share a_c09_0x_preparedbuilder and a_country_framessplitter with their reported generators (so a cell's plan and its reported value key identically —TOTALfirst, then one sheet per sorted non-nullcp_country_code), and they are the first C 09-family sign-aware sweep: both pass_C09_NEGATIVE_COLS({0081,0082,0121,0122}) explicitly, and the CRR supporting-factor adjustment columns fire non-zero and negative on the tie-out fixture (C 09.01 col 0081, C 09.02 col 0121), so the sweep reconciles the negated, row-backed deduction cells against their legs' positive magnitudes. C 09.01 carries the recorded two-basis row model: a primary cell keys the applied Art. 112 class (reporting_class_origin) while the 0020 "Defaulted exposures" memorandum keys the raw original class (exposure_class) plus the defaulted flag, so on a defaulted leg whose applied class moved the two cells of the same row drill different legs — pinned by a unit test, and the Basel 3.1 tie-out case additionally sweeps R7's real-estate rows 0090/0091 (its supporting-factor columns being CRR-only). C 09.02's value-dependent unweighted-mean fallback (_c09_02_avg_postfix, an override when a subset's total EAD is non-positive) stays a generate post-step on the reported frame the drill-down reads; it changes no cell's legs, no fixture subset triggers it, and — recorded limitation — the sign-aware sweep does not reconcile aWeightedAvgcell, so it is not that fallback's tripwire (unlike C 08.03's sum fallback). CR6 keys the obligor basis (reporting_class_origin— Annex XXII bars substitution effects, the opposite basis from CR4/CR5), executes each per-class spec over the whole alloc-PD frame (theclasses_originpredicate lives in the cell specs), forces every defaulted leg into the 100% PD band (row 17) via the derivedcr6_alloc_pdcolumn (pinned by a unit test), and injects its String PD-range label into colapost-execute (not an addressable numeric cell — skipped by the value-column sweep, the C 08.02 col-0005 precedent); it carries no "(-)"-labelled deduction column, sonegative_colsis empty. The fidelity tie-out sweep gains four cases — C 09.01TOTAL(CRR and Basel 3.1), C 09.02TOTAL(CRR) and CR6corporate(CRR). No ratchet moved:c09.pyandcr6.pystay far under themax_reporting_module_loccap, the extraction added no multi-candidatepickladder (thereporting_multi_candidate_pickscount is unchanged), and no new@citeswas added. No golden movement, no RWA/EAD/exposure change. Ref: Reg (EU) 2021/451 Annex II (C 09.01/02); CRR Art. 112/147/452(g); PRA PS1/26 Annex II/XXII (OF 09.01/02, CR6). - Report-cell lineage (drill-down) now covers the last three C 08 instrument templates — COREP C 08.03 (IRB by PD range) and C 08.05 (PD back-testing), both per exposure class, and C 08.06 (IRB slotting specialised lending), per SL type (R24). Pure refactor: no golden or reported-figure change. Each generator was split into a
<t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan]builder plus a thingenerate_<t>that executes each plan, and a_Providerwas registered inLINEAGE_PLANS. C 08.03 and C 08.05 share a sparse PD-range row axis (only populated buckets emit a row, each keyed on the derivedc08_pd_rangeband carried inrow_terms). C 08.05 is execute-only (R13 deleted its rate postfix); C 08.03 keeps two post-execute passes on the reported frame (the on/off-balance-sheet whole-bucket fallback on cols 0010/0020; the provisions ladder on col 0110). Recorded limitation surfaced in the C 08.03 scope wording: on a loans-only book that fallback fires for col 0020 (off-balance-sheet split empty) but is a value no-op — both the off-balance-sheet binding and the whole-bucket fallback sum to0.0— so no divergence arises today, with the tie-out sweep as the tripwire. C 08.06 keys sheets by SL type (CRR's IPRE absorbs HVCRE; Basel 3.1 splits HVCRE; empty SL types emit no sheet) and is the first template with a per-sheet spec: the row set is number-neutral but the empty-row set is per sheet, and an empty non-Total category row carries a fixed display risk weight in col 0070 (a zero-fill artefact, not a measured weighted average), so that one cell is left unbound — the drill-down reports the template's empty policy and reads the value from the reported frame rather than aWeightedAvgwith no legs whose value would contradict the screen; live rows and Total rows compute normally with the 0030 nominal / 0040 clamp / 0070 first-non-null live fixes and the provisions ladder applied on the reported frame. The fidelity tie-out sweep gains three cases — one representative sheet each (C 08.03corporate, C 08.05corporate, C 08.06project_finance). Ratchet note: the extraction bumpedmax_reporting_module_loc(2099 → 2320, the exact post-R24 line count, zero slack) — the mechanical additive cost of the cells/plans builders with their mandated docstrings;c08.pyalone needs a bump per wave (unlike the c07/cr4/cr8/cr7a extractions) because it hosts seven templates in one module. Splittingc08.pyinto per-template modules remains a deferred follow-up. Ref: Reg (EU) 2021/451 Annex II (C 08.03/05/06); CRR Art. 153/158/166 (IRB), Art. 452(g)/453 (disclosure); PRA PS1/26 Annex XXII. - Report-cell lineage (drill-down) now covers the two remaining C 08 instrument templates — COREP C 08.01 (IRB totals) and C 08.02 (IRB by PD grade), both per exposure class (R23). Pure refactor: no golden or reported-figure change. Each generator was split into a
<t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan]builder plus a thingenerate_<t>that executes each plan, and a_Providerwas registered inLINEAGE_PLANS. C 08.01 carries the first large Annex II §1.3 "(-)" deduction set through lineage since C 07.00 (cols 0035/0040/0050/0060/0070/0102/0103/0256/0257/0290); the tie-out sweep proves the sign-aware reconciliation on a live negated cell (col 0256 fires non-zero on the corporate_sme sheet). C 08.01's Total-row col 0080 — the cross-sheet CRM substitution inflow — is aside_contextcell whose plan threads the real per-class value, so it drills down rather than being refused; C 08.02 deliberately holds col 0080 at a constant0.0at grade grain (the recorded R12 disposition), and its string row-label col 0005 (injected post-execute) is skipped by the tie-out's numeric value-column enumeration. Ratchet note: the extraction bumpedmax_reporting_module_loc(2016 → 2099, the exact post-R23 line count, zero slack) — the mechanical additive cost of the spec/row-pred/plans builders with their mandated docstrings plus this recorded rationale;c08.pyalone needed the bump (unlike the c07/cr4/cr8/cr7a extractions) because it hosts seven templates in one module. Splittingc08.pyinto per-template modules is recorded as a deferred follow-up. - Report-cell lineage (drill-down) now covers four more templates — and the FIRST multi-sheet instrumentations since C 07.00: COREP C 08.04 and Pillar 3 CR7-A, plus the single-frame COREP C 08.07 and OF 02.01 (R22). Pure refactor: no golden or reported-figure change. Each template's generator was split into a
<t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan]builder plus a thin generator that executes the plan, and each registers inLINEAGE_PLANS. C 08.04 (IRB RWEA flow, per exposure class) is the CR8-clone flow:c08_04_plansbuilds the per-class current-period plans (no prior frame threaded), so its opening (row 0010, aprior_periodcell) and residual (row 0080, aformuladeriving from it) rows are REFUSED by the drill-down with the same distinct 404 as CR8's rows 1/8 (honesty rule 1 — the current-period ledger cannot reproduce a prior-period figure); the reportedgenerate_c08_04keeps threading the external prior frame, andc08_04_framesis the lineage-facing generator. CR7-A (extent of IRB CRM techniques, per origin approach) is a clean per-sheet extraction — no prep and no post-execute pass beyond the in-spec column-cformula, sogenerate_cr7aexecutes the plans directly and stays the provider generator;plans()andgenerate()key their sheets identically (the origin approach). C 08.07 (IRB scope of use, single frame) and OF 02.01 (output-floor comparison, single frame) each carry post-execute passes that stay on the reported frame (C 08.07's col-0040 percentage rescale and its fixed structural-null rows viac08_07_frames; OF 02.01's fixed out-of-scope rows viaof_02_01_frames) — the drill-down reads a cell's value from the reported frame, so it honours them rather than contradicting the sheet, and C 08.07's fixed-null rows read asunboundcells. OF 02.01 is Basel 3.1 only (like CMS1/CMS2): itsplans()yield nothing under CRR, so a CRR lineage request degrades to the same clean 404 as an uninstrumented template rather than crashing, and it ties out against the Basel 3.1 run; it deliberately does not re-apply theOutputFloorConfigentity gate in the lineage view (the no-config view mirrors the tie-out's reported frame). The fidelity tie-out sweep gains four cases — one representative sheet each (C 08.04corporate, CR7-Aadvanced_irb, and the two single frames). Recorded limitation surfaced in the C 08.07 scope wording: because col 0040 is aSum(ead_final)rescaled to a percentage post-execute, on a book that carried Art. 148 roll-out legs its drill-down legs would sum to the raw EAD rather than the reported percentage — no fixture carries theis_under_irb_rolloutinput today, so the cell is empty (0.0) and the sweep does not exercise the divergence. No golden movement, no RWA/EAD/exposure change. Ref: Reg (EU) 2021/451 Annex II (C 08.04/07, OF 02.01); CRR Art. 148 (C 08.07 roll-out), Art. 453(g) (CR7-A); PRA PS1/26 Annex XXII, Art. 92 para 2A (OF 02.01 scope). - Report-cell lineage (drill-down) now covers four more templates — the single-frame Pillar 3 OV1, CR5, CMS1 and CMS2 (R21). Pure refactor: no golden or reported-figure change. Each template's generator was split into a
<t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan]builder plus a thin generator that executes the plan, and each registers inLINEAGE_PLANSas asingle_frame=Trueprovider (cells reportsheet = null; thesheetquery parameter is ignored). OV1 is the first instrumented template with an out-of-frameside_contextcell (row 27's OF-ADJ, from the output-floor summary) and afirst_non_nullcell (row 26's output-floor multiplier). Because the reported template is generated WITH the run's summary but the drill-down's plan carries none, row 27 would render null against a real figure on the screen — so the resolver REFUSES it with a distinct 404 (cell reads an out-of-frame side value this drill-down does not carry), on both the REST and UI surfaces, exactly as CR8 refuses its prior-period rows (honesty rule 1: a drill-down never disagrees with the screen). The refusal is CONDITIONAL, gated on the cell'sSideContextvalue being absent from the plan's own context: C 07.00's col 0100 is also aside_contextcell but its plan threads the real per-sheet substitution inflow, so it stays drillable.generate_ov1keeps its distinct signature to thread the summary, while the newov1_framesis the provider generator. OV1's plan frame carries the derived CCR discriminators so rows 1-5 (credit risk excluding CCR) and the CCR block (rows 6/7/UK8a/9) drill down on the samerisk_type-keyed cut the generator ran, and the row-24 Art. 48(4) 250%-RW memo's recorded "other-items" approximation is surfaced in the scope wording. CR5's plan frame is the SA credit-risk population carrying the derivedcr5_rw_bucket(the Art. 123B pre-multiplier banding) andcr5_unrated(theexternal_cqsrating-presence flag, with its recorded guarantee-substitution limitation). CMS1 / CMS2 are Basel 3.1 only: theirplans()yield nothing under CRR, so a CRR lineage request degrades to the same clean 404 as an uninstrumented template rather than crashing; the fidelity tie-out sweep gained a Basel 3.1 run so the CMS pair ties out against real figures.generate_cr5/generate_cms1/generate_cms2now return the lineage-shaped single-frame dict (the dispatch router unwraps it,Noneunder CRR for the CMS pair). No golden movement, no RWA/EAD/exposure change. Ref: CRR Art. 438/444(e)/452/453; Art. 48(4) (OV1 row 24); PRA PS1/26 Art. 456(1) (CMS1/CMS2), Annex II/XX/XXII. - Report-cell lineage (drill-down) now covers four more templates — the single-frame Pillar 3 CR4, CR6-A, CR7 and CR8 (R20). Pure refactor: no golden or reported-figure change. Each template's
generate_<t>was split into a<t>_plans(results, cols, framework, errors) -> dict[str, SheetPlan]builder plus a thin generator that executes the plan, soreporting.lineagereads the very spec the generator runs (rather than a second copy that could drift). The four register inLINEAGE_PLANSassingle_frame=Trueproviders — their cells reportsheet = nulland thesheetquery parameter is ignored. CR4's plan frame is the SA credit-risk population (counterparty-credit-risk / settlement legs dropped, thefacility_undrawncommitment reclassified off-balance-sheet), and its per-column class basis split (origin class for the pre-CRM columns, post-substitution class for the post-CRM columns) is preserved exactly. CR8 is the first template whose opening row (aprior_periodcell) and residual row (aformuladeriving from it) carry prior-period figures; because the drill-down runs on the current-period ledger only it cannot reproduce them, so those cells return a distinct 404 (cell derives from the prior period; drill-down covers the current-period ledger only) on both the REST and UI surfaces, rather than a 200 with a null that would contradict a comparative-period report (honesty rule 1: a drill-down never disagrees with the figure on the screen). The IRB non-slotting population filter moved from the Pillar 3 generator intocr8.py(irb_non_slotting_population) so the reported figure and the lineage view read one population. The fidelity tie-out sweep is parametrised over all five instrumented templates. - Report-cell lineage (drill-down) machinery generalised so the remaining declarative templates can be instrumented in a few lines each, and the drill-down 404s now carry a reason (R19). No golden or behaviour change to C 07.00 lineage. The cell-lineage feature shipped with exactly one instrumented template (C 07.00 in
LINEAGE_PLANS); the shared machinery still carried C07-shaped assumptions that would have mis-signed or mis-typed any second template. Five generalisations, all additive: (1) theSheetPlanexecution-plan container moved out ofcorep/c07.pyinto a new sharedreporting/plans.py, so every template's<t>_plans()returns the same dataclass instead of being typed against C 07.00's —c07.pyimports it back, a number-neutral extraction (C 07.00 goldens byte-identical). (2)SheetPlan.negative_cols(the COREP Annex II §1.3 "(-)" deduction set, negated post-execute) is now a required field — it previously defaulted to C 07.00's set, a silent mis-sign risk for any future template sharing refs like0030/0050/0090; each template now passes its own set (orfrozenset()) explicitly. (3) single-frame templates (cr4, cr7, cr8, ov1, cms1/2, c08_07, of_02_01, …) can register via a new_Provider(single_frame=True)flag — their cells reportsheet = Noneand theirplans()/generate()return a one-entry dict, resolved by a new_resolve_sheet_keyhelper. (4)GET /api/lineagenow normalises an empty-stringsheetquery toNone(matching the UI), and both the REST endpoint and the UI route return differentiated 404s — template not instrumented for lineage vs unknown cell vs unknown run — instead of one undifferentiated "no lineage available"; a consistency guard logs loudly if a template'splans()andgenerate()key their sheets differently (a silent-null drift otherwise). (5) the fidelity tie-out (tests/acceptance/reporting/test_lineage_tieout.py) is now a per-template harness parametrised over_TIEOUT_CASES, running the full sweep (cell value == reported, kind consistency, predicate satisfaction, sign-aware reconciliation) for each instrumented(template, sheet)— a new template earns its tie-out by adding one tuple, not by cloning the file. No new template is instrumented in this change (that is R20-R26); the per-template recipe is documented indocs/features/report-cell-lineage.md. No golden movement, no RWA/EAD/exposure change — C 07.00 lineage is byte-identical, this is drill-down plumbing plus a clearer 404 contract. Ref: Regulation (EU) 2021/451 Annex I/II (COREP); CRR Part 8 (Pillar 3); Annex II §1.3 (the "(-)" sign convention).
Fixed¶
- Pillar 3 CR10 (slotting) column c now carries the maturity-correct fixed risk weight — each supervisory category splits into its two remaining-maturity rows (< 2.5y / >= 2.5y) instead of stamping the >= 2.5-year weight on every category row (R18a). Column c ("This is a fixed column. It shall not be altered" — PRA Disclosure (CRR) Part Annex XXIV; CRR Art. 153(5) Table A) was stamped from single-scalar maps carrying only the >= 2.5-year band (non-HVCRE Strong 70% / Good 90%; HVCRE Strong 95% / Good 120%) and applied to every Strong/Good row regardless of maturity. A Strong exposure with remaining maturity < 2.5 years therefore displayed 70% while the engine had already correctly risk-weighted it at the preferential 50% (Art. 153(5): Strong 50%, Good 70% under 2.5 years). This was a disclosure-accuracy defect, not a capital one — the RWEA (col e) always used the applied weight; only the displayed fixed weight was wrong. Because col c is fixed and Table A gives two weights per Strong/Good category, a single category row cannot represent a category that mixes maturities, so the fix is row-level. CR10.1–CR10.4 now split each of the five supervisory categories into its two remaining-maturity rows and close with two maturity-split Total rows (6 → 12 rows), mirroring COREP C 08.06 / OF 08.06 over the same Art. 153(5) Table A so the two slotting disclosures stay row-consistent. Each (category × band) row carries its own fixed weight (non-HVCRE Strong 50%/70%, Good 70%/90%, flat 115%/250%/0% otherwise; the Basel 3.1 HVCRE sheet CR10.5 takes Strong 70%/95%, Good 95%/120%). Legs route on the sealed
is_short_maturitydiscriminator via a module-derivedcr10_is_shortcolumn with the C 08.06 asymmetric fallback: a frame lacking the maturity column sends every leg to the >= 2.5-year band, leaving the < 2.5-year rows empty with their fixed weights still displayed. The CR10.5 equity sheet (Art. 155(2)) is not maturity-banded and is untouched. Golden impact: crr/b31pillar3__cr10__project_finance.ndjsonrestructure 6 → 12 rows — the single Strong project-finance leg (remaining maturity to 2031, >= 2.5y) now sits in the "Strong — remaining maturity >= 2.5 years" row (col c 70%, EAD/RWEA/EL unchanged) with the "Strong — remaining maturity < 2.5 years" row empty at its fixed 50%; the crrpillar3__cr10__equity.ndjsonis byte-identical. EAD, RWEA and expected loss are unchanged — this corrects only the displayed fixed weight and the row layout. Ref: CRR Art. 153(5) Table 1 / PS1/26 Art. 153(5) Table A (slotting weights incl. < 2.5y preferential split); PRA Disclosure (CRR) Part Annex XXIV (UKB CR10, col c "fixed"); Art. 438(e). - Pillar 3 UKB CMS2 row 0041 ("Of which are FIRB") column c now reports the F-IRB corporate sub-population's own actual RWA instead of that plus the entire standardised + equity corporate book (R18b). The predicate summed
rwa_finalover corporate-class legs whose origin approach was in (foundation_irb,standardised,equity), so an of-which-F-IRB sub-row disclosed the F-IRB corporate RWA plus the whole SA and equity corporate RWA — over-stating the sub-row by the entire standardised book. PRA PS1/26 Annex II defines CMS2 col c as the sum of (i) the IRB (incl. slotting) RWA and (ii) the SA RWA of the row's population; an of-which-F-IRB row's population holds no SA legs, so col c collapses to the F-IRB actual RWA (= col a) — exactly as the sibling row 0042 (of which A-IRB) mirrors its col a. The predicate is narrowed tofoundation_irbcorporates only (col a's population); col b (the SA re-computation of col a's population) and col d (full-SA at the parent corporate level) are unchanged, and the now-unused_STANDARDISED_APPROACHESlocal is removed. CMS1 has no of-which-by-approach sub-rows, so the defect cannot occur there (checked). Golden impact: b31pillar3__cms2.ndjsonrow 0041 col c66,919,060.92 → 48,244,060.92(= col a; the 18,675,000 standardised + equity corporate RWA dropped); ccr_b31pillar3__cms2.ndjsonrow 0041 col c2,500,000 → 0.0(that CCR fixture carries no F-IRB corporates — the standardised corporate CCR RWA stays in the parent row 0040 col c). Every other CMS2 cell is byte-identical. Ref: PRA PS1/26 Annex II (UKB CMS2 col c = "sum of IRB RWA + SA RWA" of the row's population); Art. 456(1)(b). - Pillar 3 CR5 "Of which: unrated" (CRR col q / UKB col ae) now reports only the exposure value with no nominated-ECAI assessment, instead of restating 100% of every class row as unrated (R17). The column was bound to the identical
Sum(reporting_ead)+ row-membership predicate as the row's own Total column, so every SA class row disclosed its entire post-CRM exposure as "unrated" — even for rated sovereigns, institutions and corporates the engine assigns real CQS-driven external risk weights to. The documented meaning (CRR Art. 444(e) / PS1/26 Annex XX; EBA Annex XX instructions) is the exposure value for which no nominated-ECAI credit assessment is available — an input availability fact, applied uniformly to every class row and independent of whether the class treatment uses the rating (a retail leg on the flat 75% RW that nonetheless carries an ECAI assessment counts as rated; an unrated corporate counts as unrated). The recorded finding notedsa_cqsis stripped by the aggregator seal (which is why COREP C 07's 0230/0235 ECAI split is structurally null in production), but the leg's own Art. 138-resolved external rating already survives sealed onAGGREGATOR_EXIT_EDGEasexternal_cqs(_calc_output_common_columns, null = no own ECAI assessment). CR5 now derives a module-ownedcr5_unrated = external_cqs.is_null()discriminator column (the establishedcr5_rw_bucket/ C 08c08_unrated_corpderived-column idiom) and conjoins it into the unrated cell's predicate — no new sealed column, no engine change, the lightest fix. Becauseexternal_cqspartitions each row's legs into rated / unrated, the column now satisfiesunrated ≤ Totalandrated + unrated == Totalper row. Frames lacking the carrier (synthetic unit frames that supply no rating info) fall back to all-unrated, so the pre-R17 column-equals-Total behaviour is preserved there (mirrors the C 08 unrated-corporate discriminator'selse pl.lit(True)fallback). On the fixturesexternal_cqsequals the SA-adjustedcqson every SA leg (no MDB-institution-lift or Art. 139(2B) SL-nulling edge cases), so the choice of the pure-inputexternal_cqscarrier is number-identical tocqswhile matching the instruction wording exactly. Known limitation (recorded): a guarantee-substituted__G_leg keeps the OBLIGOR'sexternal_cqs(own ratings only; the CRM split never repoints it) while CR5 bands that leg in the GUARANTOR's class row, so a rated-guarantor leg from an unrated obligor counts as unrated in the guarantor's row — repointing to the guarantor's rating would be an engine change, out of R17 scope;unrated ≤ Totalstill holds and no fixture carries guarantees, so no golden is affected. Golden impact is confined to the fourpillar3__cr5.ndjsonfiles (rich crr/b31, ccr_crr/ccr_b31); only column q (CRR) / ae (B31) moves, every band / Total / Other-Deducted / on-off-BS cell is byte-identical. Rich portfolio (RP-LN-SOV CQS 1, RP-LN-INST CQS 2, RP-LN-CORP-RATED + RP-LN-CRE CQS 3 are rated; the rest unrated): row 1 Central governments1,000,000 → 0, row 6 Institutions2,000,000 → 0, row 7 Corporates18,500,000 → 3,500,000(= the 3,000,000 unrated corporate + 500,000 corporate SME, the 15,000,000 rated dropped), row 17 Total23,250,000 → 5,250,000. CCR portfolio (LN_CCR_CORP CQS 2, the only SA credit-risk leg): row 7 Corporates5,000,000 → 0and row 17 Total5,000,000 → 0. EAD, RWA and risk weights are untouched — this corrects only the unrated-exposure memorandum column. Ref: CRR Art. 444(e) (CR5, col q); PRA PS1/26 Annex XX (UKB CR5, col ae); Art. 138 (external assessment resolution); Reg (EU) 2021/451 / EBA Annex XX (CR5 "of which: unrated" instructions). - Pillar 3 UKB OV1 no longer carries the pre-floor RWEA row
4aor the six pre-floor capital-ratio rows5a/5b/6a/6b/7a/7b— those are UKB KM1 rows, wrongly grafted onto OV1 (R16, part b). The authoritative PS1/26 Annex II ("Template UKB OV1 — Overview of risk-weighted exposure amounts. Fixed format", pp. 1-9) runs rows 1..29 whose only output-floor lines are row 26 (output floor multiplier, Art. 92(5)) and row 27 (output floor adjustment, Art. 92); rows 25 and 28 are "Empty set in the UK". There is no4aand no5a-7b. Those seven rows are UKB KM1 (Key Metrics) rows: KM14a"Total risk-weighted exposure amounts (RWEA) (pre-floor)" and KM15b/6b/7bthe pre-floor CET1/Tier 1/Total capital ratios (KM15a/6a/7aare the fully loaded ECL accounting model ratios, not pre-floor). P1.162 had added them toB31_OV1_ROWSreading them as mandatory UKB OV1 output-floor rows; that was wrong. The sevenP3Rows are removed fromB31_OV1_ROWS(25 → 18 rows, now matching the fixed template's CCR-block-before-equity-rows ordering), and the whole capital-ratio-override feature that fed rows5a-7bis deleted with them —Pillar3CapitalRatioOverrides(contracts/config.py+contracts/__init__.pyexport), theReportingContext.capital_ratio_overridesfield and itsside_valueratio keys, thecapital_ratioskwarg onPillar3Generator.generate_from_lazyframe/ResultExporter.export_to_pillar3/export_pillar3_facts, and the six*_ratio_pre_floor*query params onGET /api/export/{fmt}— no dead plumbing left behind. Pre-floor RWEA and pre-floor ratios genuinely belong to UKB KM1, a Key Metrics template this calculator does not produce; that is now a documented gap (the pre-floor RWEA component is still surfaced aspre_floor_rwain OF 02.01 / UKB CMS1), not a set of mislabelled OV1 rows. Docs corrected (docs/features/pillar3-disclosures.md,docs/framework-comparison/disclosure-differences.md). Golden impact: the two Basel 3.1 OV1 goldens lose those seven rows (b31andccr_b31pillar3__ov1.ndjson); every surviving cell — including row 29 (Total) — is byte-identical. CRR OV1 is untouched (it never carried them). Ref: PRA PS1/26 Annex II ("Template UKB OV1" rows 1-29; "Template UKB KM1" rows 4a / 5b / 6b / 7b); Art. 92(5)/92 (output floor); Art. 447 / 438(b) (KM1). - Pillar 3 OV1 row 24 ("Amounts below the thresholds for deduction (subject to 250% risk weight)") is now the Art. 48(4) threshold-item memo instead of any leg risk-weighted at 250% (R16, part a). PS1/26 Annex II defines OV1 row 24 as the items subject to a 250% risk weight specifically under CRR Art. 48(4) — deferred-tax assets from temporary differences and significant investments in a financial-sector entity's CET1, each below the 10%-of-CET1 deduction threshold of Art. 48(1) — disclosed "for information purposes only as the amount included here is also included in row 1". The cell's predicate was
reporting_rwin[2.495, 2.505]over the whole ledger, so under Basel 3.1 it swept in the Art. 133 equity holdings that weight exactly 250%: the reference rich B31 book mis-stated its 2,500,000 listed-equity RWEA in row 24. The sealed ledger carries no positive Art. 48(4) flag, so row 24 is now a recorded approximation (pinned intests/unit/reporting/pillar3/test_r16_ov1_row24_threshold.py): it sumsrwa_finalover legs whose origin class is the SA "Other items" bucket (reporting_class_origin == "other"— the CR4/CR5 row-16 class under which "items below deduction thresholds" are filed) and whosereporting_rwis in the 250% band. Restricting to "other items" is what excludes equity — equity exposures are definitionally not Art. 48(4) threshold-deduction items. The residual (within the "other" class we cannot distinguish a genuine Art. 48(4) item from a hypothetical non-threshold 250% "other item") is recorded in thereporting/pillar3/ov1.pymodule docstring. The narrowing is regime-clean (identical predicate under CRR and Basel 3.1). Golden impact: theb31OV1 golden's row 24 moves2,500,000 → null(col c200,000 → null); CRR row 24 was already null (CRR equity weights 290% under the simple approach, not 250%) and is unchanged. EAD, RWA and risk weights are untouched — this corrects only which legs the row-24 memo counts. Ref: CRR Art. 48(4), Art. 48(1); PS1/26 Annex II "Template UKB OV1" row 24. - COREP C 09.01 / OF 09.01 col 0080 and C 09.02 / OF 09.02 col 0110 ("RWEA pre supporting factors") now report the true pre-Art. 501/501a RWEA instead of the post-factor value, so the disclosed SME / Infrastructure supporting-factor relief is no longer silently zeroed on every row of both geographical-breakdown templates (R15). The two CRR geo templates each carry a supporting-factor column block — "RWEA pre supporting factors", the "(-) SME supporting factor adjustment", the "(-) Infrastructure supporting factor adjustment", and "RWEA after supporting factors" (C 09.01 cols 0080/0081/0082/0090; C 09.02 cols 0110/0121/0122/0125) — but the declarative specs bound the pre-SF column to the same post-factor carrier the post-SF column uses (
pick(rwa_final, rwa)) and left the two "(-)" adjustment columns as structural nulls. So on every populated row0080 == 0090(and0110 == 0125), the adjustment columns were blank, and the entire SME/infrastructure supporting-factor benefit — a figure C 07.00 (col 0215/0216) and C 08.01 (col 0255/0256) disclose correctly — vanished from the geographical breakdown. The fix binds the pre-SF column to the sealedrwa_pre_factorcarrier (the pre-supporting-factor RWA snapshot, falling back to the post-SF ladder when it is not sealed) and the two adjustment columns to Σ(rwa_pre_factor− post-SF RWEA) over each factor's applied subset, negated per the COREP Annex II §1.3 "(-)" convention — the verbatim C 07.00 / C 08.01 mechanism, including the retired asymmetric dedicated flag names (sme_supporting_factor_applied/infrastructure_factor_applied) and their sealed-ledger fallback (is_sme/is_infrastructureconjoined with the genericsupporting_factor_applied). The post-SF columns are untouched, so0080 + 0081 + 0082 = 0090and0110 + 0121 + 0122 = 0125now foot on every row and every country sheet, including the Total. The change is scoped by column presence, not by regime branching: Basel 3.1 removed supporting factors, so OF 09.01/09.02 carry none of these refs and are structurally untouched (a new module post-step negates only 0081/0082/0121/0122, an absent-column no-op on B31 frames).rwa_colis now resolved once per generate call and threaded into both the delta derivation and the cell bindings, so the delta's subtrahend is guaranteed identical to the post-SF cell's carrier (and the multi-candidatepickcount is unchanged). Golden impact. On the reference portfolio the one SME-supported book moves each geo template's corporate rows. CRR C 09.01 (SA): row 0070 ("Corporates", which fans corporate + corporate_sme in) and the Total row 0170 gain col 008013,380,950 → 13,500,000/16,228,450 → 16,347,500and col 0081null → −119,050, while col 0082 and every other populated row's 0081/0082 movenull → +0.0(a measured zero, matching C 07.00's own convention). CRR C 09.02 (IRB): row 0030 ("Corporates"), row 0050 ("Of which: SME") and the Total row 0150 gain col 0110+2,584,368.95(row 0050:13,606,784.12 → 16,191,153.07) and col 0121null → −2,584,368.95, with 0122 and the other populated rows movingnull → +0.0. The cross-template reconciliation ties out exactly: C 09.02 row 0050 pre − post (2,584,368.95) equals the magnitude of C 08.01's corporate_sme Total col 0256, and C 09.01 row 0070 pre − post (119,050) equals C 07.00's corporate_sme col 0216. Theccr_crrC 09.01 goldens move onlynull → +0.0on their populated rows (the CCR portfolio holds no supporting-factor exposure); all Basel 3.1 goldens are byte-identical without regeneration. The correctness proof lives in new synthetic-frame unit tests (tests/unit/reporting/corep/test_c09_01.py::TestC0901SupportingFactorColumns,test_c09_02.py::TestC0902SupportingFactorColumns— pre keysrwa_pre_factor, the negated per-factor adjustment, footing, the no-supporting-factorpre == postfallback, B31 columns absent, and template-to-template consistency against C 07.00's 0215/0216/0220 and C 08.01's 0255/0256/0260 pairs). EAD, RWA and risk weights are untouched — this surfaces a supporting-factor adjustment the disclosure was already computing elsewhere but never showing in the geographical breakdown. Ref: CRR Art. 501 (SME supporting factor) / Art. 501a (infrastructure supporting factor); Reg (EU) 2021/451 Annex I/II (C 09.01/09.02); PRA PS1/26 Annex I/II (OF 09.01/09.02, supporting factors removed). - COREP C 08.07 / OF 08.07 col 0040 ("Of which: % subject to a roll-out plan") is now populated from a new optional input flag instead of rendering a structural 0.0 forever, so genuine CRR Art. 148 sequential-implementation exposures are no longer indistinguishable from permanent-partial-use SA in col 0030 (R14). C 08.07 ("IRB scope of use") reports, per Art. 147 exposure class over the FULL population, the split of each class between the IRB approach (col 0050) and the standardised approach — and the SA share is itself split between permanent partial use (col 0030, Art. 150) and exposures subject to an approved roll-out plan (col 0040, Art. 148: SA today, scheduled to move to IRB). The declarative generator bound cols 0010/0020/0030/0050 but never assigned a CellSpec to col 0040 for any row in either framework, so it was the template
empty_cell="zero"default (a hard 0.0) on every sheet, and the entire SA share landed in col 0030 — the two Art. 148 vs Art. 150 populations were unreportable. No RWA/capital figure was wrong (the aggregate SA % = 0030 + 0040 and the IRB % were correct); the defect was solely the missing split. Whether an SA exposure sits under a roll-out plan is a firm-owned input fact (its approved sequential-implementation plan), not derivable from the calculation, and no discriminator existed. The fix adds a new optional Boolean input columnis_under_irb_rollout(default False) to the loan / facility / contingent schemas and threads it as a pure pass-through — loader → hierarchy (unifyloans/contingents coerce +facility_undrawn) → classifier → CRM → calc branch → aggregator — declared onHIERARCHY_RESOLVED_EDGE(optional, default False, Boolean null-fill, mirroring the Art. 113(6)intragroup_zero_rw_eligiblecarrier) and on_calc_output_common_columns/AGGREGATOR_EXIT_EDGEas a conditional column (inject=False, the R6equity_methodpattern) so it reaches the sealed reporting ledger. The guarantee and RE splits copy it to each leg unchanged (a Boolean flag is not in_stock_split_cols, so it is never pro-rata split — same asis_sme/is_adc); CCR/SFT synthetic rows resolve to False via the edge null-fill. C 08.07 then derivesc0807_rollout = (SA-treated) AND is_under_irb_rolloutand reports col 0040 = roll-out EAD / row-total EAD × 100 while col 0030 drops to the SA share EXCLUDING the roll-out slice (permanent partial use), so0030 + 0040 ==the whole SA coverage % the pre-R14 col 0030 reported. An IRB leg that happens to carry the flag is excluded (a roll-out plan carves the SA slice only). Col 0040 first carries the roll-out EADSum(the executor has no "percentage of another cell" verb) and is rescaled to a percentage in a post-execute step (_c08_07_rollout_pct); col 0030'sFormulasubtracts the same roll-out EAD, so with no roll-out data col 0040 is 0.0 and col 0030 reduces to the whole SA share bit-identical to the pre-R14 formula (x − 0.0 == x). Applies identically under CRR C 08.07 and Basel 3.1 OF 08.07. Golden impact: none — no shipped fixture suppliesis_under_irb_rollout, so on every golden the loader injects it all-False, the roll-out subset is empty, col 0040 stays 0.0 and col 0030 keeps the whole SA share (all fourcorep__c08_07frames across both frameworks byte-identical without regeneration). The correctness proof lives in new synthetic-frame unit tests (tests/unit/reporting/corep/test_c08_07.py::TestC0807RolloutPlan— roll-out %, permanent-partial %,0030 + 0040 ==the aggregate, IRB % unchanged, IRB-flag exclusion, zero-denominator guard, absent-column backwards compatibility, B31), an edge-contract test (tests/contracts/test_r14_rollout_plan_edge.py) pinning the carrier on every sealed edge from the raw lending tables to the aggregator exit, and an end-to-end carriage test (tests/integration/test_r14_rollout_plan_carriage.py) proving the flag survives the full pipeline onto the sealed results frame (drawn loan legs and the syntheticfacility_undrawnrow). EAD, RWA and risk weights are untouched — this corrects only the IRB-scope-of-use SA-coverage split disclosure. Ref: CRR Art. 148 (sequential IRB implementation / roll-out plans), Art. 150 (permanent partial use); Reg (EU) 2021/451 Annex II (C 08.07); PRA PS1/26 Annex I/II (OF 08.07). - COREP C 08.05 / OF 08.05 col 0040 ("Observed average default rate") is now internally consistent with col 0020 — it no longer silently divides by the current-period obligor count when a prior-year cohort is supplied (R13). C 08.05 is the IRB PD back-testing sheet: col 0020 reports the number of obligors at the end of the previous year, col 0030 the obligors that defaulted during the year, and col 0040 the observed default rate. The declarative executor computes col 0040 as a
Formula= col 0030 / col 0020 (the correct, internally consistent rendering — defaults over the prior-year cohort, which is exactly the denominator col 0020 reports). But a conditional post-execute pass (_c08_05_rate_postfix), carried verbatim through the Phase-7 declarative migration from the retired imperative generator, recomputed col 0040 dividing by the CURRENT-period distinct obligor count whenever a caller supplied realprior_year_obligor_countdata — so exactly when the better prior-year denominator became available, the disclosure went inconsistent (col 0040 ≠ col 0030 / col 0020 as rendered), and col 0050's copy-fallback ("Average historical annual default rate", which mirrors col 0040 when no historical carrier exists) inherited the mismatch. The fix deletes the postfix: the executor's first-passFormulaalready divides col 0030 by col 0020 (the prior-year cohort whenprior_year_obligor_countis supplied, else the current-period fallback col 0020 itself reports), so aligning the postfix to col 0020 would make it a mathematical no-op — dead code — and removing it also restores col 0050's inheritance of the corrected rate. The zero/degenerate-denominator guard is unchanged (a zero prior-year cohort yields0.0, per the template's existing convention). The accepted cross-period proxy mechanic is deliberately unchanged: col 0030 is a current-period defaulted count and col 0020 a prior-period cohort, so col 0040 = 0030 / 0020 is a cross-period proxy — that is the template's accepted PD-back-testing mechanic (Annex II C 08.05), not the defect; the defect was solely the postfix switching the denominator to the current-period count. Golden impact: none — no shipped reporting fixture suppliesprior_year_obligor_count, so col 0020 always falls back to the current obligor count and the postfix denominator equalled col 0020 anyway; the postfix never fired differently on any golden (all 28 C 08.05 frames across both frameworks are byte-identical without regeneration). The correctness proof lives in new synthetic-frame unit tests (tests/unit/reporting/corep/test_c08_05.py::TestC0805PriorYearDenominator) that construct a bucket whose prior-year sum (15) deliberately differs from the current-period distinct count (3) — the pin that would have caught the bug — asserting col 0040 = col 0030 / col 0020, that the current-period count is used nowhere, that col 0050 inherits the corrected rate, and the zero-denominator guard. EAD, RWA and risk weights are untouched — this corrects only the PD-back-testing observed-default-rate disclosure. Ref: CRR Art. 180 (PD validation); Reg (EU) 2021/451 Annex I/II (C 08.05); PRA PS1/26 Annex I/II (OF 08.05). - COREP C 08.02 deliberately does not receive the cross-class CRM substitution inflow (cols 0080/0090) — recorded as a monitored divergence, not silent drift (R12). No output change. The verified finding: C 08.01 applies the per-destination-class substitution inflow (col 0080 "Substitution inflows (+)", and its contribution to the 0090 "Exposure after CRM substitution pre CCFs" waterfall) to its Total row only, via
ReportingContext.substitution_inflow; C 08.02 (the by-obligor-grade breakdown) never applies it, so on a destination-class sheet col 0080 is 0.0 on every grade row and col 0090 is short by the inbound amount. The disposition — after establishing the right treatment — is no change, because per-grade attribution is unsound in the sealed origin-basis ledger, not merely unimplemented. (1) Every C 08 sheet keysreporting_class_origin(the obligor basis — the recorded number-neutral convergence decision), so a guaranteed leg substituted from class X into class Y physically sits in X's origin sheet (correctly reported there as an OUTFLOW, col 0070, at the obligor's grade); the inflow into Y is composed of legs that live in other sheets and never appear in Y's partition. (2) That leg carries the obligor'spd_floored/cp_internal_rating_grade, never the guarantor's — IRB parameter substitution computes the guarantor RW/EL inside a local swap-restore window without overwriting the leg's own PD/grade (engine/irb/guarantee.py::_apply_parameter_substitution;pd_flooredderives from the row's ownpd), and under CRR the guarantor is SA-RW-substituted with no guarantor PD grade at all. So banding the inflow to a grade would require the guarantor's rating grade sealed per-leg (a deferred engine enhancement the origin-basis ledger does not carry) — banding by the leg's own grade would misattribute it to a foreign obligor's grade in a different class's rating scale. The exclusion mirrors C 08.01's inflow-on-Total-row-only convention and C 07.00's class-level-scalar convention; C 08.02 additionally has no Total row (its rows are data-driven grades/PD-bands plus "Unassigned"), so there is not even a constraint-free home for a class-level scalar. Monitored consequence, now pinned: on a destination sheet the sum over grade rows of col 0080 is 0.0 (≠ C 08.01 Total 0080) and the sum of col 0090 is short of C 08.01 Total 0090 by exactly the inflow, while the OUTFLOW side (col 0070) reconciles — pinned intests/unit/reporting/corep/test_c08_02.py::TestC0802SubstitutionInflowDispositionand the module docstring's recorded-decision block, so it is a monitored decision rather than latent drift. Golden impact: none — the shipped C 08 reference portfolios carry no cross-class CRM substitution (R2), so cols 0080/0090 are already inflow-free on every golden; this change is documentation plus a synthetic-frame divergence pin, with no EAD/RWA/exposure movement anywhere. Ref: Reg (EU) 2021/451 Annex II (C 08.01/02 share the CRM-substitution column block); PRA PS1/26 Annex XXII (obligor-basis reporting bars substitution effects from the grade breakdown); CRR Art. 235 (SA risk-weight substitution) / Art. 161(3) (IRB parameter substitution). - COREP C 08.01/02 "of which: off balance sheet" memo columns 0100 and 0120 are now computed on their correct bases instead of being swapped (R11). The two "of which: off balance sheet" columns sit in different column groups and therefore report different quantities, but the generator had them crossed. Col 0100 sits in the POST-CRM PRE-CCF group (headed by the 0090 "Exposure after CRM substitution pre CCFs" waterfall), so it must report the off-balance-sheet share of that pre-conversion-factor amount — but it was bound to
Sum(ead_final)over the off-BS legs, i.e. the post-CCF exposure value. Col 0120 sits in the EXPOSURE VALUE (post-CCF) group and must report the off-BS share ofead_final— but it was hardwired to null. The fix is a swap plus a re-derivation: 0120 now carriesSum(ead_final)over thec08_bs == "off"legs (exactly what 0100 used to compute — a move, not a rewrite), and 0100 is derived per row in the new_c08_off_bs_pre_ccfpost-step as the off-BS slice of the 0090 waterfall, mirroring_crm_waterfallcomponent-for-component on positive magnitudes read from the raw ledger:off-BS gross (0020: floored drawn+undrawn) − off-BS guarantees (0040) − off-BS credit derivatives (0050) − off-BS other funded collateral (0060) − off-BS substituted portion (0070). The 0090 waterfall itself, R10's EL/provisions work, and the Annex II §1.3 "(-)" negation ordering are untouched. Recorded decision — the 0080 substitution inflow is excluded from 0100: it is a total-row cross-sheet scalar (ReportingContext.substitution_inflow, a per-destination-class aggregate) with no leg-level on/off-BS attribution, so an off-BS memo cannot claim a share of it — this matches the 0090 convention that the inflow only lands on the constraint-free total row. The executor has no intra-row sub-waterfall verb, so 0100 is a module post-step (the established C 08 idiom, alongside_provisions_postfixand the C 08.03 balance-sheet fallback), applied to both C 08.01 and C 08.02, which share the value surface. Golden impact. The reference reporting portfolio is loans-only (every IRB leg is on-balance-sheet), so there is no off-BS IRB exposure: col 0100 stays0.0and the only movers are col 0120null → 0.0on the populated rows of all sixteencorep__c08_01__*/corep__c08_02__*files (CRR + B31) — 0120 now behaves as a proper "of which" memo (a measured0.0on an on-BS-only book, matching col 0100's own convention) rather than a hardwired blank. The non-trivial pre-CCF-vs-post-CCF behaviour (0100 > 0120 when a 50%-CCF off-BS facility is present, and 0100 == the off-BS row's own 0090) is proven by new synthetic-frame unit tests intest_c08_01.py/test_c08_02.py. EAD, RWA and risk weights are untouched — this corrects only the two off-balance-sheet memorandum disclosures. Ref: Reg (EU) 2021/451 Annex I/II (C 08.01/02 column layout: 0090/0100 POST-CRM pre-CCF, 0110/0120 exposure value post-CCF); CRR Art. 111 / Art. 166 (exposure value and conversion factors); PRA PS1/26 Annex I/II (OF 08.01/02). - COREP C 08.01/02 expected-loss columns 0280/0282 now report the real slotting expected loss on specialised-lending sheets instead of a masked 0.0, and the C 08.01/02/03/06 provisions ladder now reads a sealed carrier instead of a column the aggregator seal strips (R10). Two independent defects on the IRB templates. (a) EL masking on slotting sheets. Col 0280 (expected loss; the Basel 3.1 pre-post-model-adjustment figure, twinned with 0282 "EL after post-model adjustments") read
el_pre_adjustmentwhenever that column existed, filling its null values to 0.0.el_pre_adjustment/el_after_adjustmentare produced only by the formula-IRB post-model-adjustment step (engine/irb/adjustments.py::apply_post_model_adjustments), so they are NULL on slotting legs (whose EL comes from the slotting calculator and rides onexpected_loss), while the aggregator injects those columns onto the sealed frame for the whole run under both frameworks (CRR's disabled branch copiesexpected_lossintoel_pre_adjustment). TheSum-with-null-fill therefore reported a hard 0.0 for slotting EL on the specialised-lending sheets — even though C 08.06 col 0090 (Sum("expected_loss")) reported the same slotting EL correctly (a cross-template inconsistency). Cols 0280/0282 now resolve through a per-leg coalesce (_preparederivesc08_el_pre=coalesce(el_pre_adjustment, expected_loss)andc08_el_after=coalesce(el_after_adjustment, expected_loss)): the formula-IRB adjustment EL where non-null, else the baseexpected_loss. This is a value no-op on formula-IRB legs (thereel_pre_adjustment == expected_loss) and surfaces the true slotting EL on slotting legs — a per-leg coalesce, deliberately not the retired column-presence branch. (b) Provisions ladder fell back to a stripped column. The shared_provisions_postfix(serving C 08.01/02 col 0290, C 08.03 col 0110, C 08.06 col 0100) preferred the SCRA/GCRA base sum and, when it netted to ~0, fell back toprovision_held— butprovision_heldis an input pass-through the aggregator seal strips (not onAGGREGATOR_EXIT_EDGE), so"provision_held" not in colsreturned early on every real submission and the provisions cells rendered 0.0. The fallback now reads the sealedprovision_allocatedwhen the frame carries noprovision_held. Carrier decision —provision_allocated, not C 07.00'sprovision_deducted(R9): the Art. 111(2) drawn-first deduction is SA-only, soprovision_deducted = provision_on_drawn + provision_on_nominalis structurally 0.0 on every IRB/slotting leg (engine/crm/provisions.py: IRB/Slotting →provision_on_drawn = 0,provision_on_nominal = 0), whereasprovision_allocatedis allocated for all approaches (it feeds the IRB EL shortfall/excess) — so it is the only sealed carrier that is regulatorily meaningful for the IRB book;provision_deductedwas considered and rejected as it would report a constant 0.0. The recorded C 08 per-cell granularity is preserved (aggregate scra/gcra over the row subset, swap the whole cell when it nets to ~0), distinct from C 07.00's per-row ladder;provision_heldstays the preferred fallback where a synthetic frame supplies it, so the change is a strict superset of the retired behaviour. Golden impact. Part (a): confined to the fourcorep__c08_01__specialised_lending/corep__c08_02__specialised_lendingfiles (CRR + B31). On the reference portfolio the specialised-lending slotting book carries 300,000 expected loss: C 08.01 rows 0010 (Total) / 0020 (on-balance-sheet) / 0080 (slotting-approach Total) col 0280 move0.0 → 300,000.0(B31 additionally moves col 0282 the same), and C 08.02 row "Unassigned" col 0280 (B31 also 0282) moves0.0 → 300,000.0— each equal to C 08.06[project_finance] col 0090 (300,000.0, unchanged — the cross-template tie-out). Part (b): no golden movers — the reference portfolio's IRB/slotting legs carry zeroprovision_allocated, so col 0290/0110/0100 stay 0.0; the fix's proof lives in new synthetic-frame unit tests (test_c08_el_provisions.py). EAD, RWA and risk weights are untouched — this changes only the EL and provisions memorandum disclosures. Ref: CRR Art. 158 / PRA PS1/26 Art. 153(5A), 158(6A) (IRB EL and post-model adjustments); CRR Art. 111(2) (SA drawn-first provision deduction, SA-only); Reg (EU) 2021/451 Annex I/II (C 08.01/02/03/06). - COREP C 07.00 column 0030 ("(-) Value adjustments and provisions associated with the original exposure") now reports the firm's actual SA provisions instead of a hard 0.0, so the net-exposure waterfall (col 0040 = 0010 − 0030) no longer collapses to the gross figure on every real submission. The cell was bound to
SafeSum(("scra_provision_amount", "gcra_provision_amount")), but those two columns are input pass-throughs that are stripped at the aggregator exit (never sealed onto the reporting ledger) — so under the COREPempty_cell="zero"policySafeSumrendered a hard0.0on every real run, indistinguishable from "no provisions", understating the Annex II provisions disclosure and leaving col 0040 equal to the gross original exposure. Col 0030 now resolves through a preference ladder with the same preference order as C 08.01/02's provisions ladder — the SCRA/GCRA input pass-throughs win when a book supplies them non-degenerately, otherwise the cell reads the sealed SAprovision_deductedcarrier (CRR Art. 111(2):provision_on_drawn + provision_on_nominal) — but at a different granularity: C 07's pick is per exposure row (each row takes its own scra/gcra else its ownprovision_deducted, then the cell sums), whereas C 08 decides per cell (aggregate scra/gcra over the row subset, swap the whole cell when it nets to ~0); the two differ only on a frame where some rows carry scra/gcra and others do not, and per-row is the deliberate, test-pinned C 07 contract.provision_deductedis the exact amount the drawn-first EAD math removes from the gross exposure (on-balance for EAD =drawn − provision_on_drawn, nominal =nominal − provision_on_nominal), so col 0040 reconstructs the engine's own net-of-provisions basis.provision_allocatedwas considered and rejected: it can exceedprovision_deductedfor an over-provisioned exposure (the deduction is capped at drawn+nominal by themin()inprovisions.py), so it would over-report 0030 and break the0040 = engine-net-basisidentity. The carrier is derived pre-execute(ac07_provisiondiscriminator column in_prepare) rather than as a post-executepatch like C 08's 0290, because C 07's col 0040 Formula must consume the corrected magnitude in the same pass; the Annex II §1.3 "(-)" display negation of 0030 is unchanged (a zero deduction stays+0.0). CCR synthetic rows (SFT / derivative netting sets) carry a null/zeroprovision_deducted, so they contribute nothing to 0030. The cell is now lineage source-backed (a measured zero, not "cannot compute"). Golden impact: none — neither the rich reporting portfolio nor the CCR reporting portfolio carries any SA provisions (provision_deducted = 0.0on every row), so both golden gates are byte-identical; the fix's proof lives in new synthetic-frame unit tests. EAD, RWA and risk weights are untouched — this changes only the provisions/net-exposure disclosure columns. Ref: CRR Art. 111(2) (SA drawn-first provision deduction); Reg (EU) 2021/451 Annex II §1.3 (C 07.00 cols 0030/0040). - COREP C 02.00 / OF 02.00 row 0420 ("Equity IRB") now holds only equity actually treated under the IRB approach, so the SA-vs-IRB approach breakdown is correct under both frameworks. The template booked the whole equity book into row 0420 unconditionally — even under Basel 3.1, where PS1/26 Art. 147A removes the IRB equity treatment (all equity is standardised) so row 0420 cannot legitimately hold anything — while the same RWA was simultaneously booked into the SA breakdown (row 0060 total + class row 0210). The headline figures were never wrong (rows 0010/0040/0050 are flat ledger sums that count equity once, and the 0060 + 0220 = 0050 footing held with equity parked in 0060), but row 0420 asserted an IRB-equity population that under Basel 3.1 does not exist and under CRR should contain only Art. 155 equity, not SA-treated equity. The C 02.00 pre-pass kernel (
reporting/corep/c02.py::_aggregate_by_approach) now partitions the equity book on the sealedequity_methoddiscriminator (the R6 conditionalaggregator_exitcolumn:sa= Art. 133,irb_simple= Art. 155(2),pd_lgd= Art. 155(3)): IRB-method equity (irb_simple/pd_lgd) reports at row 0420 and folds into the IRB total (row 0220), excluded from the SA rows; SA-method equity — and any equity leg whose method the ledger did not seal (defensive null) — stays in the SA breakdown (rows 0060 / 0210), excluded from 0420. The routing is data-driven (no regime branch): under Basel 3.1 every equity leg is stampedsa(Art. 147A), so row 0420 empties by construction, and the 0060 + 0220 = 0050 footing is preserved under either routing because IRB-method equity leaves the SA total exactly as it enters the IRB total. Golden impact is confined to the twocorep__c_02_00files. Basel 3.1: row 0420 empties (col 0010/0020/00302,500,000→0.0); the SA rows 0060/0210 keep the 2,500,000 equity book and every other cell — including the totals — is unchanged. CRR (the reference fixture's equity is a listed 1,000,000 holding risk-weighted at 290% under Art. 155(2), so it is genuine IRB equity): row 0420 correctly keeps2,900,000, while the double-booked SA side now empties — row 006019,128,450→16,228,450and class row 02102,900,000→0.0— and the IRB total row 0220 gains it (126,383,017.29→129,283,017.29); the totals (0010/0040/0050) and the footing are unchanged. EAD, RWA and risk weights are untouched — this moves RWA between approach-breakdown rows only. Ref: CRR Art. 155 (IRB equity); PRA PS1/26 Art. 147A (Basel 3.1 IRB equity removed); CRR Art. 92 (own funds requirements); Reg (EU) 2021/451 Annex II (C 02.00). - COREP OF 09.01 (geographical breakdown, SA) now reports Basel 3.1 real-estate exposures and specialised-lending granularity instead of leaving those rows permanently null. Under Basel 3.1 the "Real estate exposures" parent row 0090 and its of-which sub-rows 0091-0095 (regulatory residential RE / regulatory commercial RE / other RE / land ADC / SME), plus the SA specialised-lending of-which rows 0071-0073 (object / commodities / project finance), were structurally null:
_c09_01_row_predresolved every row by a single reverse lookup into the sharedC09_01_SA_CLASS_MAP, and the B31-only row keys (real_estate,re_*,sl_*) have no map value, so the predicate wasNoneand_null_empty_rowsnulled the rows permanently. For specialised lending this lost only granularity (SL maps tocorporate, so the money stayed visible in the corporate parent row 0070), but for real estate it was a missing-money defect: the B31 RE reporting classes (retail_mortgageretail RRE, plus the SA loan-splitter'sresidential_mortgage/commercial_mortgagesecured legs) matched no class row at all, so RE exposure was absent from every class row and survived only in the country Total — the class rows no longer summed to the Total. A new_c09_01_re_sl_predbranch (consulted before the reverse-map lookup, so CRR row keys never reach it) keys row 0090 on the RE class union over the applied/original basis, splits 0091-0094 by the sealedproperty_type/is_adcdiscriminators and a derived null→True regulatory-RE flag (c09_re_qualifying, matching C 07.00'sc07_qualifying_re), routes 0095 byis_sme, and keys 0071-0073 by the basis-independentsl_type; the row-emptiness helper_either_predwas corrected to keep an RE sub-row's basis-independent discriminator terms (previously it dropped them, which would have un-nulled a residential sub-row whenever any commercial RE existed). Income-producing commercial RE classified ascorporate(its sealedreporting_class_origin) is deliberately not pulled into the RE rows — that keeps row 0090 a clean class partition and matches the CRR row 0070 treatment. CRR C 09.01 is untouched (the shared class map is unchanged; the RE/SL keys occur only inB31_C09_01_ROWS). Golden impact is confined to the twob31c09_01 files (TOTAL + the single GB country sheet): on the reference rich portfolio the one retail residential mortgage (400,000 EAD, 98,333.33 RWEA), previously missing from every class row, now populates row 0090 and row 0091 (0010/0075exposure 400,000,0090RWEA 98,333.33); the Total row and every other row are unchanged, and the class rows now sum to the Total. EAD, RWA and risk weights are untouched — this surfaces geographical-breakdown exposure the disclosure was already computing but never showing. Ref: PRA PS1/26 Art. 124A-124L (real estate as a standalone SA class); Art. 122A (SA specialised lending); Reg (EU) 2021/451 Annex I/II (OF 09.01). - Pillar 3 CR10.5 ("Equities under the simple risk-weighted approach") now discloses a firm's Art. 155(2) equity RWEA instead of a structurally empty sheet. Under CRR, equity risk-weighted with the IRB simple approach (Art. 155(2): 190% sufficiently-diversified private equity / 290% exchange-traded / 370% other) must be disclosed in CR10.5 (Art. 438(e)), but
generate_cr10filtered the whole CR10 population toreporting_approach_origin == "slotting"while equity legs seal asreporting_approach_origin == "equity"— so CR10.5 was force-emitted empty (and, worse, carried the slotting supervisory-category rows Strong/Good/Satisfactory/Weak/Default rather than the three equity RW bands). Two changes fix it. (1) The equity calculator now records which CRR equity method it applied on a new sealedequity_methoddiscriminator (sa= Art. 133,irb_simple= Art. 155(2),pd_lgd= Art. 155(3)) — the existing sealedreporting_methodcollapses every equity leg toEQUITYand cannot tell them apart. It is a conditional (inject=False)aggregator_exitcolumn: present on any run holding equity, simply absent from an equity-free run, so the eager-backed seal stays a shallowDataFrame.lazy()wrap. (2) CR10 now draws per-subtemplate populations — CR10.1–4 from the slotting book, CR10.5 from the simple-RW equity legs (reporting_approach_origin == "equity"andequity_method == "irb_simple", so Art. 133 SA and Art. 155(3) PD/LGD equity are excluded) — with three fixed-RW band rows (190/290/370%) plus a Total; each leg lands in the band matching its appliedreporting_rw. Equity is an on-balance-sheet holding with no off-BS/CCF split, so col a mirrors col d (exposure value) and col b is null; col f discloses the Art. 158(7) equity EL. CR10.5 is still force-emitted (empty, with its fixed RW column) when a CRR firm has IRB equity permission but no simple-RW holdings. Basel 3.1 is untouched — Art. 147A removes IRB equity, so UKB CR10 has no equity sub-template (it carries HVCRE at CR10.5). Golden impact is confined to the one CRRpillar3__cr10__equityfile: on the reference rich portfolio the single 1,000,000 listed equity holding, previously silently dropped, now discloses in the 290% "Exchange-traded" band and the Total — col a/d exposure1,000,000, col e RWEA2,900,000(= 1,000,000 × 290%), col f EL8,000(Art. 158(7) 0.8%). EAD, RWA and risk weights are untouched — this surfaces an equity RWEA the disclosure was already computing but never showing. Ref: CRR Art. 155(2) (IRB simple equity); Art. 158(7) (equity EL); Art. 438(e) (CR10.5); PRA PS1/26 Art. 147A (Basel 3.1 IRB equity removed); Reg (EU) 2021/451 Annex XXIV. - Pillar 3 CCR8 and COREP C 34.08 ("Exposures to central counterparties") now scope to CCP counterparties only — the whole bilateral OTC book no longer inflates the non-QCCP rows. Both templates split CCP exposures into a QCCP row and a non-QCCP row, but the non-QCCP figure was computed as the global complement of the QCCP-trade discriminator (
~((cp_entity_type == "ccp") & cp_is_qccp.fill_null(True))), so it swept in every ordinary bilateral derivative counterparty — an institution, corporate or any non-CCP netting set — even though those are not exposures to a central counterparty at all (they belong in CCR1/CCR2, CRR Art. 439(f)/(h)). The non-QCCP row is now the CCP-restricted complement —cp_entity_type == "ccp"and~cp_is_qccp.fill_null(True), i.e. a counterparty that IS a central counterparty but one the firm does not treat as qualifying (Art. 107(2)(a)) — and CCR8's Total (row 21) sums only the CCP population (row 1 + row 2). CCR8 also now includes CCP-faced FCCM SFTs (include_sft=Truebefore the CCP restriction): a securities-financing transaction cleared through a CCP is a CCP exposure under Chapter 6 Section 9's material scope (CRR Art. 301(1)(b)), matching how Pillar 3 OV1 already routes it to row UK8a; a bilateral SFT is dropped by the CCP restriction, so C 07.00 row 0090's SFT population is unaffected. C 34.08 is now emitted only when the portfolio actually has CCP exposures (a CCP trade leg or an Art. 308/309 default-fund contribution) — a book of purely bilateral derivatives, whosec34_08field is nowNone, matches Annex II's CCP-exposures intent and the bundle's own docstring (catalog/facts/export already tolerate an absentc34_08, as they must for a CCR-free book). CCR1/CCR2/CCR3 and C 34.01/02 (the SA-CCR analysis-by-approach and per-netting-set templates, which cover the whole derivatives book) are untouched, so the bilateral netting set still reports there. Golden impact is confined to the four CCR-portfolio files: on the reference portfolio (one QCCP-cleared swap, one bilateral institution swap) the bilateral swap leaves the non-QCCP rows — CRR CCR8 row 22,748,345.55/5,496,691.10(RWEA/EAD) →null/nulland row 21 Total2,858,279.37/10,993,382.20→109,933.82/5,496,691.10(the QCCP row alone); C 34.08 row 00205,496,691.10/2,748,345.55(EAD/RWEA) →0.0/0.0; Basel 3.1 the analogous cells (1,462,778.17/4,875,927.25, Total1,560,296.72/9,751,854.50→97,518.54/4,875,927.25; C 34.08 row 0020 →0.0). EAD, RWA and risk weights are untouched — this changes only which legs the two CCP-exposure disclosures count. Ref: CRR Art. 439(i) (CCR8/C 34.08 CCP exposures); Art. 306(1) (QCCP trade RW); Art. 107(2)(a) (non-QCCP treated as institution); Art. 301(1)(b) (SFTs in the CCP material scope); Reg (EU) 2021/451 Annex II (C 34.08). - Basel 3.1 equity now contributes its standardised-equivalent RWA to the disclosed S-TREA — OF 02.01, C 02.00 and CMS1/CMS2 no longer silently drop it. Under PS1/26 Art. 147A the IRB equity treatment is removed, so equity is standardised-only and an equity leg's standardised-equivalent RWA IS the (pre-floor) RWA the equity calculator already produces. But equity bypasses the SA calculator, so its
sa_rwacarrier was null, and every disclosure that sumssa_rwaover the whole book — OF 02.01 col 0040 (S-TREA), C 02.00 col 0020 (SA-equivalent TREA; and its x0.08 own-funds twin on row 0040), CMS1 col d and CMS2 col d — omitted equity's contribution on any Basel 3.1 run holding equity. The aggregator's equity-prep step (engine/aggregator/_equity_prep.py) now populates equity'ssa_rwaas its own pre-floor RWA, gated on theoutput_floorpack Feature so it mirrors the SA calculator's ownsa_rwagate exactly (nosa_rwacolumn is minted on CRR frames that never carry one — which also keeps C 08.01/02 col 0276 hard-null under CRR). Equity is not floor-eligible, so the output-floor base (_s_trea/_u_trea),rwa_finaland every capital figure are unchanged: this is a disclosure-completeness fix, not a capital change. On the reference rich portfolio the disclosed S-TREA rises 161,655,833.33 → 164,155,833.33 (+2,500,000 = the one listed equity holding's 1,000,000 EAD × 250% Basel 3.1 SA risk weight), while TREA/U-TREA stay 137,449,963.91 and the floor stays non-binding; the "OF 02.01 col 0040 == C 02.00 col 0020" single-S-TREA tie-out continues to hold. Goldens move on the four affected b31 templates only. EAD, RWA, risk weights and the output floor itself are untouched. Ref: PRA PS1/26 Art. 147A (IRB equity removed); Art. 133(3) (SA equity 250%); Art. 92 para 2A (output-floor S-TREA); Reg (EU) 2021/451 Annex II (OF 02.01, C 02.00, CMS1/CMS2). - Pillar 3 CR4/CR5 now compute every cell over one SA-credit-risk population, so the templates internally reconcile. The sealed
reporting_on_balance_sheetdiscriminator is null for synthetic exposure types outside {loan, facility, contingent}, so counterparty-credit-risk legs were excluded from the on/off-balance-sheet split cells (CR4 cols a-d, CR5 ba/bb) while remaining in every class-total / risk-weight-band / RWEA cell — CR4 disclosed RWEA for exposure its own exposure columns never showed (the ccr_crr golden's Total exposure-weighted density printed an impossible 1.07). A new shared scope helper (reporting/pillar3/sa_scope.py::sa_credit_risk_population) now applies one population to both templates: SA-CCR / FCCM-SFT netting-set legs (ccr_netting_set), CCP default-fund contributions (ccr_default_fund) and settlement failed trades (ccr_failed_trade) are excluded from every cell — they are CCR-series / settlement disclosures (Art. 439; Art. 307-309; Art. 378-380), not Art. 444(e) SA credit risk (this also makes the population regime-independent instead of leaning on Basel 3.1'sstandardised_ccroutput-floor relabel, which had left CRR wrong and B3.1 accidentally right) — andfacility_undrawnsynthetic legs are kept and reported off-balance-sheet (a genuine undrawn commitment: gross feeds col b/bb, post-CCF EAD feeds cols c/d, per Art. 111). COREP C 07.00's deliberately CCR-inclusive population (Annex II rows 0090-0130) is untouched, as is the sealed column itself (CR6/CR10 make their own scope calls). Golden impact: the twoccr_crrCR4/CR5 files only — the two SA-CCR derivative legs leave every cell (CR4 Total col e 5,358,279 → 2,500,000; density 1.0716 → 0.5, now ≤ 1). EAD, RWA and risk weights are untouched — this changes only which legs the two disclosure templates count. Ref: CRR Art. 444(e); Art. 439; Art. 378-380; Art. 307-309; Art. 111. - COREP C 07.00 and C 08.01/02 now report every "(-)"-labelled deduction column as a negative figure, so the supporting-factor and CRM-substitution waterfalls foot per Annex II §1.3. COREP Annex II Section 1.3 requires columns whose label carries a leading "(-)" to be reported as negative values; the estate applies this via a per-module post-execute negation pass, but two template surfaces had incomplete column sets and emitted these deductions as positive magnitudes. C 07.00 (
corep/c07.py) now also negates the CRR supporting-factor adjustments 0216 ("(-) SME supporting factor adjustment") and 0217 ("(-) Infrastructure supporting factor adjustment", Art. 501/501a), so the RWEA block foots as0215 + 0216 + 0217 = 0220under the display convention instead of the columns fighting the "(-)" label. C 08.01/02 (corep/c08.py) — which previously negated only col 0290 — now negates the full "(-)" set on its shared value surface: the CRM substitution outflows 0040/0050/0060/0070 (both frameworks), Basel 3.1's on-balance-sheet netting adjustment 0035 and slotting financial-collateral adjustments 0102/0103 (structural-null today, so the negation is a no-op that keeps the sign truthful if a carrier is ever wired), and the CRR supporting-factor adjustments 0256/0257, bringing C 08 into line with C 07 (whose col 0035 was already negated). The negation runs strictly after the CRM waterfall (0090 = 0020 − 0040 − 0050 − 0060 − 0070 + 0080) and every intra-row formula have consumed the positive magnitudes, so no footing is double-flipped; the pass intersects with the frame's columns, so the framework-specific members (0035/0102/0103 under B3.1, 0256/0257 under CRR) are no-ops in the regime where the column is absent. A zero deduction is normalised to+0.0via a shared_negate_exprused identically in both modules (plain float negation flips the IEEE sign bit and Polars keeps the−0.0, so an explicit zero branch is required — the previous(−col) + 0.0did not clear it). Because C 07.00 is the lineage-instrumented template, its cell-lineagesignfield (read fromSheetPlan.negative_cols) now correctly labels 0216/0217 as negated. Golden impact is confined to the CRR corporate-SME sheets where these columns fire non-zero (C 07.00 col 0216, C 08.01/02 col 0256): the magnitudes are unchanged, only the sign flips. EAD, RWA and the net-exposure waterfalls are untouched — this changes only the display sign of the disclosure deduction columns. Ref: Reg (EU) 2021/451 Annex II §1.3 (C 07.00, C 08.01/02); CRR Art. 501/501a (supporting factors). - Gross-exposure template cells can no longer report a negative figure when a book uses the negative-deposit netting convention. A negative
drawn_amount/interestis a deliberate on-balance-sheet netting input (CRR Art. 195/219): a deposit or credit balance offsets the loans that share itsnetting_agreement_reference. The EAD path already floored these locally (drawn_for_ead/interest_for_eadclip at 0), but the raw carrier columns sealed negative onto the aggregator exit, so every COREP/Pillar 3 "original / gross exposure" cell that summed them (C 07.00 col 0010, C 08.01/02 cols 0020/0030, C 08.03 col 0010, C 08.06 cols 0010/0030, CR4 col a, CR5 cols ba/bb, CR6 cols b/c, CR10 cols a/b) could understate — or go negative — because a deposit's−200,000was added straight into the gross figure. The aggregator's reporting projection now seals four floored gross carriers —reporting_gross_drawn/reporting_gross_interest/reporting_gross_nominal/reporting_gross_undrawn= the raw amount clipped at 0 (nulls preserved, never filled to 0 — anti-conservative), computed after the CRM guarantee split so they are leg-consistent — and every gross-exposure cell reads these instead of the raw twins (COREP falls back to the raw column when the sealed twin is absent on older synthetic frames; Pillar 3's pre-built specs read the sealed twin, mirrored onto unit frames by the reporting-ledger shim). On books where every carrier is already non-negative the clip is the identity, so all existing goldens are byte-identical; only a book carrying a negative on-balance amount moves, and it moves from a wrong (understated/negative) figure to the correct floored one. EAD, RWA and the risk-weight are untouched — this changes only how the gross-exposure disclosure columns are summed. A new non-blockingDQ010data-quality warning flags a bare negativedrawn_amount/interestthat carries nonetting_agreement_reference— a negative balance that cannot net against anything is a data error (a genuine netted deposit under a shared reference is silent). Ref: CRR Art. 111 (SA gross exposure value); Art. 166 (IRB exposure value); Art. 195/219 (on-balance-sheet netting).
[0.3.17] - 2026-07-21¶
Added¶
- Multi-entity reporting: run individual, sub-consolidated, and consolidated submissions from one dataset. Two new optional input tables —
config/reporting_entities(a reporting-hierarchy registry) andmapping/book_entity_mapping(booking book → reporting entity) — plus new optionalintragroup_entity_reference/guarantor_entity_reference/book_codetag columns on the exposure and guarantee schemas, let a scoped run resolve one entity's population: consolidated/sub-consolidated submissions take the entity's subtree with intragroup exposures and guarantees eliminated (CRR Art. 6 / 11-18), individual submissions take the entity alone with intragroup positions retained. Each scope is a full, independent pipeline run — its own output floor, prior-period file, and run-index fingerprint/reconciliation workspace — via a new no-op-by-defaultresolve_scopepipeline stage (six SCP001–SCP006 data-quality codes, never raised).reporting_entity/reporting_basisare available onCalculationConfig,CreditRiskCalc, and the calculator/comparison/reconciliation REST endpoints and UI forms; a newGET /api/entities+/hierarchypage renders the registry tree. Unscoped runs are provably byte-identical to before. See Multi-Entity Reporting. - Art. 113(6) core-UK-group 0% risk weight for intragroup exposures on solo returns. On an
individual-basis run, an SA-routed intragroup exposure is assigned a 0% risk weight when both the reporting entity and the tagged intragroup counterparty carrycore_uk_group=Truein the reporting-entity registry (CRR Art. 113(6), retained under PS1/26 — the packintragroup_zero_rwFeature is enabled in both regimes). The scope resolver computes the per-row eligibility on the newintragroup_zero_rw_eligiblecarrier (defaultFalse, so the treatment is pure opt-in), which is threaded through the hierarchy → classifier → CRM edges to a final SA risk-weight override applied after every standard assignment and CRM adjustment. It is keyed on a row's own intragroup tag: a guarantee-split leg of an eligible loan inherits the 0%, while an external loan merely guaranteed by a group member does not (Art. 113(6) covers direct exposures to members, not protection from them). Scope this wave: SA lending exposures (facilities, loans, contingents, undrawn commitments). Excluded, by design: IRB exposures (the Art. 150(1)(e) permanent-partial-use route reclassifies them to SA upstream), equity holdings in group entities, and CCR/SFT netting sets.consolidated/sub_consolidatedruns are unaffected (intragroup rows are eliminated before weighting) andcore_uk_group=Falsebooks are byte-identical to before. See Multi-Entity Reporting.
[0.3.16] - 2026-07-20¶
Fixed¶
- The "Real-estate split" stage no longer stalls for minutes on portfolios with unattested equity collateral — the error-channel dedup is linear again, and CRM018 is one rolled-up warning instead of one per row. The P1.271 listing gate (v0.3.14) conservatively rules equity collateral with unknown index membership / listing ineligible, and emitted a CRM018 warning per gated row — 13k+ warnings on a 100k-exposure book when the attestation columns are unpopulated. The re_split stage adapter dedups splitter errors against the CRM channel with a list-membership scan that is O(N×M) in the two lists, which the flood turned quadratic: ~11s of a ~20s pipeline run at 100k scale, surfacing in the rwa-ui stepper as the "Real-estate split" step hanging (reported in the field as c.40s → 5min+, run abandoned). Fixed at both ends: the dedup builds a set of the prior CRM errors once (frozen-dataclass equality preserved via hashing — identical semantics, linear cost), and
_record_non_main_index_equity_ineligiblerolls CRM018 up to a single count-carrying warning per run, following the splitter's RE002–RE004 per-cause idiom. Measured at 100k (CRR): re_splitter 11,080ms → 254ms, total run 19.9s → 8.1s, error channel 13,361 → 4 entries. The gate itself (value zeroing + eligibility clearing) is untouched — RWA outputs are identical; only warning cardinality and wall time change. Ref: CRR/PS1-26 Art. 197(1)(f)/198(1)(a).
[0.3.15] - 2026-07-20¶
Added¶
- COREP C 08.04 (IRB RWEA flow statement) can now actually state a flow — it gains the prior-period capability its Pillar 3 counterpart always had. CR8 could populate opening/closing RWEA and a signed residual from
previous_period_results; C 08.04 had no prior-period wiring anywhere, so its flow columns were permanently null by construction.COREPGenerator.generate()/generate_from_lazyframe()now acceptprevious_period_results(defaultNone), threaded to the C 08.04 module exactly as the Pillar 3 generator threads CR8's: row 0010 (opening RWEA) evaluates the same population predicate over the prior sealed frame via the executor'sPriorPeriodbinding, row 0090 (closing) is unchanged, and row 0080 ("Other") is the signed residualclosing − openingso the statement foots by construction — the attribution driver rows (0020–0070) stay null rather than inventing decompositions the engine cannot support (mirroring CR8, PS1/26 Annex XXII para 11). Population symmetry is the load-bearing property and is review-verified plus test-pinned: opening and closing both read the non-slotting IRB population keyed on the sealed obligor class (reporting_class_origin), so this period's opening equals last period's reported closing — a prior slotting row cannot leak into the opening, a class new this period gets a null opening with its full closing in the residual, and a fully-run-off class emitting no sheet is a recorded, documented limitation of the per-class-sheet pattern. Without a prior period the output is byte-identical to before. The test shim seals synthetic prior frames through the same reporting-ledger projection as current frames. The capability is wired end-to-end: the sharedprior_run_idcontract onGET /api/export/{fmt}and the UI templates route now covers the COREP formats too (the prior run's sealed results parquet is what gets threaded — the shape C 08.04's obligor-class keying requires), so the flow statement populates in the workbook, the cell-fact feed and the on-screen viewer alike. Ref: Reg (EU) 2021/451 Annex II C 08.04; PS1/26 Annex XXII para 11. - The flow, comparative and ratio rows a real user could never populate now populate — prior-period and capital-ratio inputs reach the generators end-to-end.
Pillar3Generatoralways acceptedprevious_period_results,capital_ratiosandoutput_floor_summary(feeding CR8's RWEA flow statement and the CMS1/OV1 pre-floor ratio rows), but no delivery path ever supplied them — every workbook and on-screen view carried null flow and ratio rows, structurally. Three fixes compound: (1)CalculationResponsegainsoutput_floor_summary— the aggregator computed it every Basel 3.1 run and the formatter silently dropped it at the API boundary; it now threads automatically into every Pillar 3 export (OV1's floor-adjustment row populates from the run's own data, no caller input), survives run-index persistence (a reloaded run exports the same floor rows it exported before the restart — round-tripped throughrun_index.jsonwith tolerant loading of pre-existing files), and is pinned by real unmocked pipeline-run tests (Basel 3.1 populates; CRR isNoneby design). (2) Comparatives are explicit, never guessed:GET /api/export/{fmt}and the UI templates route gainprior_run_id, resolved with the same strictness as the reconcile contract — unknown id 404s, a failed run, mismatched framework or a reporting date not strictly earlier 422s with the reason — because flow rows silently keyed to the wrong prior period would misstate a disclosure (book identity is documented as the caller's responsibility, per the reconcile precedent). With a valid prior run, CR8's opening RWEA and signed residual populate; template-bundle caching keys on(run_id, prior_run_id)so a with-prior view never cross-serves a without-prior request. (3) The six pre-floor capital ratios (cet1/tier1/total, actual and transitional) are optional query parameters mapping ontoPillar3CapitalRatioOverrides. Ref: PS1/26 Annex XXIV (UK CR8); PS1/26 Annex II (UKB OV1/CMS1). - Cell-fact export — a machine-mappable feed of every reported cell, and exports that finally say whose return they are (
reporting/facts.py). The COREP/Pillar 3 workbooks are a human review artifact: merged two-row banners, fixed filenames (rwa_corep.xlsx), no entity or period anywhere — a vendor filing tool (the realistic road to an XBRL/BEEDS submission) cannot cleanly ingest them, and the artifact does not identify which firm or reference date it belongs to.build_fact_frame()flattens a generated template bundle into one row per cell —template_id,sheet,row_ref/row_name,col_ref/col_name,value,text_value,framework— reusing the same catalog traversal the on-screen viewer trusts, preserving the load-bearing null-vs-0.0 distinction end-to-end (parquet and ndjson both round-trip nulls, test-pinned), splitting String cells (grade labels, PD-range labels) intotext_valuerather than dropping them, and nulling non-finite floats exactly as the Excel writer does. A frozenFilingMetadata(reporting date, framework, run id, optional entity identifier, generator version) stamps constant columns onto the fact rows, writes ametadatasheet into each workbook (viawrite_stringthroughout — a free-form entity identifier beginning with=lands as a literal string, never a live formula, pinned by an XML-level test against the classic spreadsheet-injection class), and derives stamped download filenames (rwa_corep_CRR_2026-06-30.xlsx) from server-validated fields only — the unsanitised entity identifier deliberately never reaches a filesystem path.ResultExportergainsexport_corep_facts/export_pillar3_facts(parquet/ndjson) andGET /api/export/{fmt}gains the four*_facts_*formats plus anentity_identifierquery parameter; existing workbooks are byte-identical when no metadata is supplied. Ref: Reg (EU) 2021/451 Annex I/II; PS1/26 Annex I/II. - Cross-template tie-outs — the templates now check each other (
reporting/tieouts.py). Until now nothing asserted that the COREP schedules agree among themselves or with the Pillar 3 disclosures — the one thing that separates "template data" from a return a firm can sign.check_cross_template_consistency(corep, pillar3, framework)runs a curated list of five comparable-aggregate ties — the C 02.00 grand-total RWEA against OV1 row 29, the C 02.00-internal credit-risk roll-up (0050 = 0060 + 0220), the C 07.00 sheet aggregate against C 02.00's SA line net of equity (equity folds into C 02.00[0060] by design but C 07.00 has no equity sheet — the naive tie is wrong and only passes on equity-free books), the C 08.01 sheet aggregate against C 02.00's IRB line, and C 08.01 against OV1 rows 3+4+5 on the sharedreporting_approach_originbasis — and emits any breach on the standardCalculationErrorchannel (TIE001, business-rule category), never an exception. Tolerance follows the golden convention (rtol 1e-9 with a small absolute floor) because Polars float sums are legitimately non-deterministic at byte level. Just as deliberately, the module records six non-comparable pairs with their regulatory reasons — UK CR6/CR7/CR9 sit on the obligor basis (PS1/26 Annex XXII bars substitution effects from back-testing), C 08.07 keys the raw Art. 147 origination class, C 09.01 is two-basis, CR4 mixes bases per column block — so a future maintainer can't "fix" the gap by asserting equalities the regulation forbids. A missing template, absent row or null cell fail-safe skips the tie (null means "not produced", not zero — and a partially-resolvable multi-row term skips rather than summing what it found). All five ties foot to zero on real CRR and Basel 3.1 pipeline runs. New:tests/unit/reporting/test_tieouts.py,tests/acceptance/test_tieouts_pipeline.py. Ref: CRR Art. 92(3); Reg (EU) 2021/451 Annex II; PS1/26 Annex XXII.
Changed¶
- Every consumer-read aggregator frame is now contract-sealed, and the exit seal has exactly two sanctioned call sites. The sealed results ledger was the only brand-checked frame on
AggregatedResultBundle— the by-class/by-approach/by-class-method summaries, the floor-impact frame and the supporting-factor frame that the UI cards and analysis layer read had no schema guarantee at all, and the exit edge was independently re-sealed in a second module for the BA-CVA broadcast column. Five newEdgeContracts (declared faithfully from the producers in_summaries.py/_floor.py/_supporting_factors.py) are sealed at the producer and registered inSEALED_FRAME_FIELDS, so a drifted frame now fails loudly at bundle construction; a newreseal_with()helper incontracts/edges.pyis the one sanctioned post-aggregator mutate-and-rebrand (it re-runsconform()and raises on any column not declared on the edge — the CVA path now goes through it), with the two-seal-point invariant documented onAGGREGATOR_EXIT_EDGEitself; and aREPORTING_SURFACEfrozenset names the 14 canonical reporting columns (the tenreporting_*projections,guarantee_rwa_benefit,rwa_final/ead_final/rwa_pre_floor) so a template author no longer has to guess which of the ~313 exit-edge columns are the surface and which are calculation carry-through. The shared test fixture now refuses to null-complete an under-populated summary frame (three fixtures were silently relying on that — now populated with the real producer columns), and a new unit drift-guard strict-seals the real summary producers' output against their edges. Number-neutral: sealing and annotation only — zero expression, filter or group-by changes. The previous format double-framed the grid — a scroll pane with a near-invisible border inside a padded panel — so on wide templates (C 07.00's 28 columns, C 08.02's 49) columns were chopped mid-digit at an edge that read as accidental clipping, and on tall templates the pane's horizontal scrollbar sat below the fold, leaving no visible way to reach the remaining columns. The grid card is now itself the window: a header strip (template title, sheet, column count, and the template/sheet picker folded in as a slim toolbar — reclaiming ~200px of page chrome) over a scroll pane sitting flush inside the card frame, so columns clip exactly at the card border like a freeze pane. The pane is fitted to the viewport remainder (report-grid.js, with a CSS fallback), keeping its horizontal scrollbar on screen at all times, and pane scrollbars are now thin and visible on the dark canvas. Frozen headers, row labels, cell keys, drill-down links and the null-vs-zero distinction are unchanged.
[0.3.14] - 2026-07-19¶
Added¶
- The report-template pages now use the full desktop width, with a frozen header and row labels. Regulatory templates are wide — C 08.02 under Basel 3.1 has 49 value columns, C 07.00 has 28 — and the 1100px reading measure (right for prose, wrong for a return) showed only ~6 of them at a time. Three changes compound: the report pages opt into a full-bleed container (prose keeps its measure via
page-intro); the grid becomes a bounded scroll pane, which is what givesposition: stickya scrollport to work against — the group band and column refs stay pinned at the top, and the row ref/name columns are frozen to the left, so scrolling right no longer loses track of which row you are on; and the grid runs at a compact density with tabular numerals, so digits line up column-wise. Roughly 6 visible columns becomes ~20 on a 1920px display and ~29 on a 2560px one — C 07.00 fits on screen in one go. This also fixes two latent bugs: the sticky header did nothing (the wrapper had no height cap, so it never scrolled internally), and the two header rows both pinned totop: 0, which would have stacked them on the same line. The column count is now shown next to the sheet title. Cell keys, drill-down links and the null-vs-zero distinction are unchanged. - Click a report cell to see what produced it. The template viewer's cells are now drill-down links: clicking one opens its lineage — the reported value, the sum of contributions and whether the two reconcile (allowing for the deduction-column sign convention; a genuine mismatch is surfaced as a defect rather than hidden), what the cell measures, the criteria a leg must satisfy, the population those criteria ran over, and the contributing exposure legs. The page states plainly when a cell cannot be explained by exposures at all: it derives from other cells (
0040 = 0010 − 0030), it comes from outside the ledger, its sources are never produced (so a reported0.0is the empty-cell policy, not a measured zero), or no legs match it. Criteria on template-derived discriminators (a risk-weight band, a CCF bucket) are labelled as such, so they are not mistaken for sealed facts about the exposure. Cells are only offered as links on templates that have lineage — a link never leads to a shrug, and asking for lineage anyway is a clean 404. New:ui/views/lineage.py,cell_lineage.html. - Report-cell lineage — ask a reported figure which exposures and rules produced it (
GET /api/lineage). Given a cell key (template, sheet, row ref, column ref) the drill-down returns what the cell means — its metric, its filter criteria, the scope of its population, its basis (aggregator_exit) and its sign convention — plus the ledger legs that fed it. Lineage is a query, never a stored index: a cell's lineage IS itsCellSpec(the Phase 7 declarative executor made C 07.00 col 0220 literallyCellSpec(Sum("rwa_final"), predicate=…)), so the drill-down reads the very spec the generator executes and re-runs that sameRowPredicateover the same prepared frame. It never re-implements a template's row selection — a second copy could silently disagree with the figure actually reported. To make that reuse possible without duplication,c07.pynow exposesc07_plans()(a number-neutral split of plan-building from execution; the 95 goldens are byte-unchanged). Two honesty rules are pinned by the tie-out suite:cell_valueis read from the generated template (the number the user clicked is ground truth, never recomputed), andcontribution_totalis reported separately with asignflag reconciling the two across the Annex II §1.3 deduction-column negation. Cells are classified into six kinds that fall straight out of the binding vocabulary —rows(contributing legs),formula(derives from other cells, e.g.0040 = 0010 − 0030),side_context,prior_period,constant,unbound— so every cell gets a truthful answer, not just the summable ones. Crucially, a cell whose source columns the engine never produces (C 07.00 col 0030 sums SCRA/GCRA provisions) still reports0.0under the COREP zero policy; lineage flags itis_source_backed: falsewithmissing_columnsand a nullcontribution_total, distinguishing "we computed zero" from "we cannot compute this". A contributor is a leg, not an exposure (guarantee substitution splits an exposure), so every row carriesreporting_leg_role, both class endpoints andsource_exposure_reference. Coverage is explicit: C 07.00 today; any other template — including the still-imperative C 34.x / CCR1–8, which have noTemplateSpecto read — is a clean 404, never a re-derived guess. New:reporting/lineage.py,features/report-cell-lineage.md. - Report templates are now viewable in the UI — the COREP and Pillar III templates a run produced, on screen, sheet by sheet. Until now the templates existed only as a downloaded workbook: reading one figure meant exporting Excel. A run's results page links a new viewer (
/results/{run_id}/templates) with a template picker (grouped COREP / Pillar III), a sheet picker for the per-sheet templates (C 07.00 / C 08.x by exposure class, C 09.0x by country, C 34.02 by netting set, CR6/CR9 by class), and the regulatory grid — row refs and names down the side, 4-digit column refs across the top under their logical group band. Two additive read endpoints back it:GET /api/templates(the templates this run produced, with their sheet keys) andGET /api/templates/{template_id}(one sheet's column headers and rows). Templates are generated once per run and cached, so switching template or sheet re-renders rather than recalculates. Cells are shown exactly as the generator produced them, including the Annex II §1.3 "(-)" sign convention — nothing in the viewer recomputes, re-signs or re-fills a value. Crucially, a null cell renders distinctly from a reported zero (—vs0): an inert/empty row or a never-produced source is not the same statement as a computed zero, and flattening them would misstate the return. Every value cell carries its cell key (template_id,sheet,row_ref,col_ref) as data attributes — the address a report cell is known by, and the handle the forthcoming cell-lineage drill-down attaches to (docs/plans/report-cell-lineage.md). New:reporting/catalog.py(one uniform view over both generator bundles),ui/views/report_templates.py,features/report-template-viewer.md. - The results output now carries a canonical reporting projection — ten
reporting_*columns that name the guarantee-substitution ledger once, at the source (Phase 7 S2). The per-exposure results frame has always physically been a two-leg substitution ledger (CRM splits each guaranteed exposure into__G_<guarantor>guaranteed legs and__REM/__REM_FL/__REM_SENretained legs), but no column said so: COREP, Pillar 3, the UI and the reconciliation each re-derived class/approach/method from a different mix of the raw twins (exposure_classvs_appliedvs_post_crm;approach_appliedvs_post_crm), which is exactly where the pre/post-CRM complexity crept in. The aggregator now computes, after the residual multiplier and the output floor, and seals onAGGREGATOR_EXIT_EDGE:reporting_class(post-substitution class, =exposure_class_post_crm, Art. 235),reporting_class_origin(obligor applied class, =exposure_class_applied, Art. 112),reporting_approach/reporting_approach_origin(the post-/pre-substitution approach twins),reporting_method(the STD/FIRB/AIRB/SLOTTING/EQUITY label of the post-substitution approach, materialised frommethod_label_expr),reporting_leg_role(whole/guaranteed/retained— names the physical leg split so nobody sniffs reference suffixes; COREP C 07.00 substitution outflow/inflow reconstruct as two sums over theguaranteedlegs),reporting_on_balance_sheet(declared at source fromexposure_type, the same rule the reporting kernel applies today; null = neither side), andreporting_subclass/reporting_ead/reporting_rwaliases. No consumer reads the new columns yet — this slice is provably cell-neutral (all 95 reporting goldens structure-identical; full suite green) — the summaries, reconciliation, reporting templates and UI retarget to them in the follow-on slices of the Phase 7 plan. Pinned bytests/unit/test_aggregator.py::TestReportingProjection. Ref: CRR Art. 235 / Art. 112; Reg 2021/451 Annex I C 07.00. - Reconciliation can now reuse a calculation you already ran, instead of silently re-running the whole pipeline.
CreditRiskCalc.reconcile()embedded an unconditionalcalculate(), so the calculate → reconcile flow cost two full engine runs over the same data.reconcile()now acceptscalculation=(an already-completedCalculationResponse; the embedded run is skipped and the cached results parquet is reconciled directly), and a newrwa_calc.api.run_indexmakes the reuse safe by construction: each calculation is fingerprinted by its parameters plus a stat-based(relpath, size, mtime_ns)signature of every input file, captured before the run, andfind_reusablerecomputes the signature at lookup — any input change, a failed run or a vanished results parquet misses and forces a recompute. The UI reconciliation form offers a pre-ticked "Use results from the calculation completed at …" checkbox when a fresh matching run exists (a passive "input data has changed" note when only the data moved), re-verifies at submit time and degrades silently to a full run on any miss; on reuse the progress stepper ticks every engine stage instantly and parks on the reconcile tail.POST /api/reconcilegains an optionalrun_idfor the same reuse over HTTP with the opposite contract — an explicit run_id is an instruction, so unknown ids 404 and a mismatched framework/date, failed run or expired results 422, never a silent recompute. Covered bytests/unit/api/test_run_index.py,tests/unit/api/test_service_reconcile_reuse.py, and new cases intests/integration/test_ui_reconciliation.py+tests/integration/test_rest_api.py. - Calculation reuse now covers every flow and survives an app restart. Follow-ons to the reconciliation-reuse feature above: (1) persistence — the UI app configures
run_indexpersistence at startup, so each UI run's parquet cache lives under$RWA_STATE_DIR/runs/<run_id>/(default~/.rwa_calc/), registrations write through torun_index.json(capped at the 10 most recent runs, oldest evicted), and a restart reloads the index and re-registers the runs so both the reuse offers and their/results/{run_id}pages come back; run directories are never deleted mid-session — unreferenced ones are swept at the next startup, when nothing can be serving them. (2) Every run seeds the pool — the comparison page formats and indexes both of its embedded framework runs (the comparison itself still computes from the rich bundle: its capital-impact attribution needs floor-impact/pre-factor frames a cached response does not persist), a full reconciliation's embedded calculation is exposed asReconciliationResponse.calculationand indexed by the worker (a recon-first session gets the reuse on its next run), andPOST /api/calculate/POST /api/comparisonindex their runs too. (3) Calculator banner — when the calculator form's pre-filled values match a fresh indexed run, a non-blocking "already ran — view its results" banner links straight to the existing results page. Covered by new persistence unit tests intests/unit/api/test_run_index.py(restart round-trip, corrupt-file tolerance, cap eviction, orphan sweep) and new integration tests intests/integration/test_ui_app.py+test_ui_reconciliation.py(banner, comparison seeding, recon-first seeding, reuse-survives-restart) +test_rest_api.py.
Added (Phase 7 — declarative reporting groundwork)¶
- Guarantee RWA relief is now a first-class, reconcilable number: the sealed per-leg
guarantee_rwa_benefitcolumn lands on the results output (Phase 7 decision F8, recorded). Reconciliation previously tied out guarantee EAD only — when a firm's legacy numbers disagreed on the relief a guarantee produced, the mismatch diffused into unattributable blended risk-weight and RWA deltas. The aggregator now seals, per physical guaranteed leg,ead_final × guarantee_benefit_rw— the leg's EAD times (borrower-basis risk weight minus the substituted risk weight), which is exactly the difference between the two Art. 235 terms for the covered portion. The recorded definition is pre-supporting-factor and pre-floor (the branch snapshots the delta before Art. 501/501a and the portfolio output floor, isolating the substitution effect), and because it reads the applied delta it ties exactly to the relief the engine granted — including the Art. 153(3) double-default override and the Art. 160(4) no-better-than-direct floor on IRB parameter substitution. Retained, whole and non-beneficial legs carry 0.0; the column is null where the substitution machinery never ran — slotting legs (the recorded slotting-guarantee zero-relief gap is deliberately kept visible, not papered over as "no relief due") and unguaranteed runs. Reconciliation gains the additiveguarantee_rwa_benefitcomponent (skipped with a REC001 warning when a legacy extract doesn't map it), so relief mismatches get their own component row. Pinned by aggregator unit tests (per-leg arithmetic, multi-guarantor additivity, the null-not-zero contract) and a full-pipeline hand-calc acceptance twin on the P1.110 book: a 1M loan, borrower corporate CQS 5 (150% both regimes), guarantor corporate CQS 3 → benefit 500,000 under CRR (150%−100%) and 750,000 under Basel 3.1 (150%−75%). Additive and number-neutral for every existing output; the reporting goldens passed without regeneration. Ref: CRR/PS1-26 Art. 235, Art. 236/161, Art. 153(3), Art. 160(4). - The declarative executor gains its grouped-aggregation kernel — template generation is 7–15× faster and the full dev-loop test suite drops from ~12 to ~4.5 minutes (Phase 7, recorded follow-up closed). The executor previously filtered the frame once per cell (rows × columns eager filters), and each eager expression-filter carries ~7ms of query-plan overhead regardless of frame size — measured at 26s/76s/63s for a single C 08.01/C 07.00/CR6 generate call on 10–14k-row frames.
cellspec.execute()now builds one subset per distinct predicate (cells share their row's few predicates;RowPredicateis frozen and hashable), compiles all predicate masks in a singleselectper frame, and filters by boolean mask (~10× cheaper than expression filters); prior-period masks compile against the prior frame's own columns so the presence-tolerant semantics are unchanged. Two new public batched helpers —cellspec.subset_rows(keyed batched subsets) andcellspec.matched_counts(one select of mask sums, no filters at all) — replace the per-rowpred.applyloops in the C 07/C 08/C 09 module post-passes. Number-neutral by construction (identical subsets feed the identical evaluation): the reporting golden gate passed without regeneration, and all 988 reporting unit tests pass unchanged. Measured: C 08.01 14.9×, C 07.00 13.1×, CR6 6.8×; the golden acceptance gate fell 65s→14s. - The Phase 7 capstone lands: the reporting test estate is split per template, the remaining consumers converge on the sealed ledger, and the architecture is ratcheted (Phase 7 Sn). The 9,525-line
test_corep.py— the single-file xdist straggler — splits intotests/unit/reporting/corep/(one file per template family, a dedicated cross-template file preserving the C 07+C 08 cross-checks, and a shared builders module), cutting the unit-suite wall time by roughly a third with exact collection parity. The reconciliation collapse drops its dead RWA/EAD candidate ladders for the sealed names; both template generators re-type their entry points onto a structuralResultsSourceprotocol so the reporting layer no longer imports upward fromapi/— retiring the two "Retired by Phase 7" import-direction inversions for real. Three new shrink-only ratchets bank the reporting estate (max_reporting_module_loc, the multi-candidatepick()ladder census, and a per-template test-file ceiling), andanalysis/gains its missing upward import-direction rule. Recorded §9 scope-downs (in the plan doc): the raw-class read-ban is re-scoped per the F3/F4 recorded bases (COREP keys rawexposure_classby design), the check-17 extension to reporting'sframework ==branching is deferred as slice-sized, the sealed-column read-allowlist is sequenced behind the F6 per-column decisions, and the watchfiresource_pathswidening is blocked on the instrument allowlist (Reg 2021/451 is not expressible). The/next-itemsreviewer gains the reporting-slice criteria (no bulk-regen-to-green; number-changing slices carry decisions; each slice names its D1–D10 kill), and the forced-single-stream list adds the reporting-projection files in all three of its synced homes. - COREP C 02.00 / OF 02.00 (the master own-funds roll-up) moves to
reporting/corep/c02.py— completing the COREP credit-risk estate; only the C 34.x CCR family remains on the legacy path (Phase 7 S8). This template is the recorded exception to the cellspec executor: a portfolio-total cross-approach roll-up whose rows are pre-computed aggregates with value-dependent fallbacks, ported per the plan's Kind-9 mandate as typed pre-pass aggregation kernels feeding a thin row-assembly shell. The one structural change is that the generator's three instance-state dicts (the IRB class/approach map, the slotting SL-type map, and the B31 SME/FSE/property sub-row map — previously set, read and reset across methods) become pure-function returns; every behaviour is otherwise relocated verbatim and pinned: the output-floor column reporting the post-floor total (rwa_finalis already post-floor —rwa_pre_floorfeeds only the floor-activated indicator with its 0.01 epsilon), equity RWA appearing in three rows by design while the flat total counts it once, the many-to-one accumulating SA class map, the sub-row split fallbacks that place the whole total in one bucket when no sub-data exists,exposure_subclassas the canonical corporate split signal with the flag heuristic as fallback, the F-IRB/A-IRB asymmetry in financial-large-corporate routing, the Basel 3.1 column policy (approach parents zero the comparison columns while sub-rows mirror them), the currency-mismatch memo row with null comparison columns outside the TREA total, the zero-fill vs null-fill row regimes, and the entity-scoped floor indicator rows. Exactly number-neutral: goldens passed without regeneration in both regimes; 64 C 02.00 unit tests, 38 reporting-basis tests and the 7 P2.41 exposure-subclass acceptance pins pass unchanged. Ref: CRR Art. 92; PRA PS1/26 Art. 92 para 2A/3A/5, Art. 123B, Art. 147A(1)(e)/(f). - COREP OF 02.01 (the Basel 3.1 output-floor comparison) is declarative (Phase 7 S8). The imperative generator method and its row builder move to
reporting/corep/of02.pyas one TemplateSpec: rows 0010 (credit risk excluding CCR) and 0080 (Total) carry identical full-portfolio values — the recorded collapse for a credit-risk-only calculator, where the Total is deliberately not a sum of the risk-type rows — and the six out-of-scope risk-type rows (CCR, CVA, securitisation, market, operational, other) stay a fixed all-null set via a post-pass. The modelled column sumsrwa_pre_floor(the pre-floor carrier — deliberately, sincerwa_finalis already post-floor), the standardised columns sumsa_rwa, and U-TREA is the intra-row sum of the two per Annex II §1.3.2. The B31-only and entity-scope gates stay outside the executor: CRR returns a None bundle field (no frame), and the exemption reads onlyOutputFloorConfig.is_floor_applicable()— the delegate keeps its extraoutput_floor_configkeyword, a recorded signature divergence from the other declarative delegates. An empty portfolio still yields the full 8-row frame with zeros on the populated rows. Exactly number-neutral: goldens passed without regeneration; all 80 OF 02.01 and reporting-basis unit tests pass unchanged. Ref: PRA PS1/26 Art. 92 para 2A/3A; PS1/26 Annex II §1.3.2. - COREP C 09.01 and C 09.02 (the geographical breakdowns) are declarative (Phase 7 S8). Both per-country generators and their 29 imperative helpers and 7 constant maps move out of
generator.pyintoreporting/corep/c09.py— including the five helpers already orphaned by the earlier C 07/C 08 ports and the IRB pre-filter dispatch plumbing. One spec per template per framework executes once per country sheet ("TOTAL" first over the whole population — null-country rows included — then one sheet per sorted country code). Both templates keep their rawexposure_classrow keying, which differs deliberately from C 07.00's applied ladder: a defaulted SA exposure stays in its raw class row and lights the "of which defaulted" column while the "Exposures in default" row stays null, and under Basel 3.1 the reclassified real-estate mortgage appears only in the Total row — both golden-verified behaviours, preserved not fixed. Also preserved verbatim: C 09.01's permanently-null sub-rows (the reverse-map keying short-circuits before the SME/short-term/CIU/real-estate filters ever run — recorded dead code), the deliberately narrow column ladders (a single gross column, a two-wide RWEA ladder with no post-factor RWA, the CRR "pre supporting factors" RWEA equalling the post-factor value with structurally-null adjustment columns), C 09.02's PD/LGD averages as raw ratios weighted by final EAD readinglgd_post_crmonly — with the retired unweighted-mean fallback on zero-EAD subsets reproduced as a module post-step — the per-cell defaulted null-vs-zero asymmetry, and the two distinct non-SME filters (the null-dropping corporate variant vs the null-keeping anti-join). The port is exactly number-neutral: the golden gate passed without regeneration across all 8 C 09 frames in both regimes, and all 80 C 09 unit tests pass unchanged. Ref: Reg (EU) 2021/451 Annex I/II (C 09.01/02); PRA PS1/26 Annex I/II (OF 09.01/02); CRR Art. 112/147. - COREP C 08.06 and C 08.07 are declarative — the C 08 family is now fully through the one executor (Phase 7 S8). The two imperative generators and their eleven helpers (the SL-type/HVCRE router, the category×maturity row filter, the zero-fill builder, the four C 08.06 value computers, the C 08.07 group-by aggregator and per-row value computer, plus the already-orphaned deduction-negation residue) are deleted from
generator.py; both templates live inreporting/corep/c08.py. C 08.06 (specialised-lending slotting, one sheet per SL type) preserves its per-row two-branch policy as a module post-pass: an empty non-Total row zero-fills every cell and reports the row definition's fixed display risk weight ("50%" → 0.5) in the risk-weight column, while live rows — and both maturity-split Total rows, even when empty — compute on data with per-cell null policy. Preserved verbatim and pinned: the framework-divergent HVCRE routing (CRR's IPRE sheet absorbs HVCRE only whenis_hvcreexists; Basel 3.1 splits HVCRE into its own sheet), the asymmetric no-maturity-column fallback (short band empties, long band absorbs the category), the permanently-empty "substantially stronger" sub-rows, CRR's preference for post-supporting-factor RWEA, the post-CRM carrier fallback to original exposure, the whole-subset nominal fallback for the off-balance original column, the first-non-null risk weight on zero-EAD subsets, and the SCRA/GCRA→provision_heldprovisions ladder; SL types with no exposures emit no sheet. C 08.07 (IRB scope of use) reads the FULL population — SA enters every denominator, a null approach falls to the SA side, and slotting counts as IRB — keyed on the rawexposure_class; the coverage percentages are intra-row Formulas guarding zero denominators to 0.0; the structural-null rows are a fixed set (the opposite of C 07.00's empty-subset rule — empty real-class rows stay 0.0); and the Basel 3.1 SA-RWEA "other" column is derived as total-minus-IRB so the Annex II additive identity holds by construction. One recorded finding: the C 08.07 materiality-column gating onoutput_floor_configwas dead code — the flag was threaded but never read, and the materiality columns are unconditionally null on every reporting basis (pinned by the existing reporting-basis tests) — so the declarative signature drops the parameter outright. The port is exactly number-neutral: the golden gate passed without regeneration in both regimes, and all 120 C 08.06/07 unit tests pass unchanged. Ref: CRR Art. 153(5), Art. 147(2), Art. 148, Art. 150; Reg (EU) 2021/451 Annex I/II (C 08.06/07); PRA PS1/26 Art. 153(5) Table A, Art. 147B/150(1A) (OF 08.06/07). - The entire COREP C 08 IRB family (C 08.01/02/03/04/05) is declarative in one slice — including C 08.02, whose data-driven String-keyed rows were previously judged an executor misfit (Phase 7 S8). The five imperative generators and their shared value engine (the ~40-column computer with seven column helpers, the per-class builders, the grade-row builder, the PD-range computers) are deleted; all five run through the one cellspec executor from
reporting/corep/c08.py, sharing one framework-agnostic value surface filtered per regime's column set. C 08.02 lands via the CR9.1 pattern: rows are discovered at generate time (distinct firm rating grades when supplied, else the populated fixed PD bands, plus an "Unassigned" residual) over a derived key column, and the String obligor-grade column 0005 is injected post-execute from the row name. Every recorded subtlety is preserved verbatim and pinned: the column-presence-vs-value-nullness distinction (a present-but-nullel_pre_adjustmentmasksexpected_losson slotting sheets — the golden behaviour), the Annex II negation set being column 0290 only (the CRM waterfall 0090 = 0020−0040−0050−0060−0070+0080 runs on positive magnitudes), maturity reported in days (years ×365), C 08.04's deliberately narrower RWA ladder, C 08.03's whole-bucket on/off-balance-sheet fallback, the value-dependent SCRA/GCRA→provision_heldprovisions ladder (a shared post-step), C 08.05's null-filled arithmetic means and point-in-time prior-year/historical fallbacks, the Basel 3.1 allocate-on-pre-floor-PD/report-post-floor split, sparse bucket emission with the 9999 "Unassigned" row, and the LFSE sub-splits gated oncp_apply_fi_scalarcolumn presence. All five dicts keep the rawexposure_classkeying (identical to the sealed obligor basis for IRB rows — probe-verified zero movers); the cross-sheet substitution inflow reuses the C 07ReportingContextside input. The port is exactly number-neutral: the golden gate passed without regeneration across all 28 C 08 frames in both regimes, and all 732 COREP unit tests (including the P4.20 internal-grade suite) pass unchanged. Ref: CRR Art. 142-191, Art. 153, Art. 180, Art. 501/501a; Reg (EU) 2021/451 Annex I/II; PRA PS1/26 Annex I/II (OF 08.0x). - COREP C 07.00 is declarative — the first COREP template through the one executor, and the substitution outflow/inflow keystone (Phase 7 S8, decision F4). The SA credit-risk template's imperative estate (the 24-column value computer, the CRM/CCF/RWEA column helpers, the risk-weight-band section builder, the four row-section subset builders and their C 07.00-only filter family) is deleted; the per-class sheets now run as one TemplateSpec per framework in
reporting/corep/c07.py, executed per obligor-class sheet over the sealed ledger. F4 is executed as preserve-verbatim: sheets key the obligor applied-class ladder (identical to the sealedreporting_class_origin), specialised lending merges into corporate before keying, the substitution outflow keeps the raw pre/post-class semantics via a derived flag, and the cross-sheet inflow is precomputed per destination class and threaded to the total row through a newReportingContext.substitution_inflowside input — so the net-exposure waterfall Formula (0110) consumes it in-pass, with the COREP Annex II §1.3 "(-)" sign convention applied as a post-step after the waterfalls consume positive magnitudes. Row subsets (defaulted/SME/materially-dependent/qualifying-RE ladders, RW bands, CCF buckets, SL types and phases, PPU/roll-out provenance, equity-transitional and currency-mismatch memo rows) become tolerant-equals terms over module-derived discriminator columns (RowPredicate.equalsnow accepts Boolean values), with empty or source-less rows rendering all-null exactly as before and never-produced cells (CCR splits, ECAI splits withoutsa_cqs, CCF buckets withoutccf_applied) staying structurally null. The port is provably number-neutral: the golden gate passed without regeneration across all 16 per-class sheets in both regimes, and all 713 COREP unit tests pass unchanged — no test shim was needed because the module keeps its raw-column keying (the ledger two-leg equivalence stays pinned by the aggregator oracle; the COREP ledger-field convergence and a COREP ledger shim are recorded follow-ups). Also recorded: the S8-pre golden authoring and the Pillar 3 CCR1/2/3/8 family are deferred on the legacy path, and the C 08 family is the next slice (C 08.02's data-driven String-keyed rows are the known misfit). Ref: CRR Art. 111-113, Art. 501/501a; COREP Annex II C 07.00 ¶40-43/¶56/¶56A; PRA PS1/26 Annex II/App. 17. - Pillar 3 CMS1/CMS2 are declarative — completing the Pillar 3 credit estate — and the two output-floor comparison templates finally agree on the total actual RWA: equity-approach exposures now count on the standardised side of CMS2, as the instructions require (Phase 7 S8). The imperative bodies and the five CMS2 row helpers are deleted; both templates live in
reporting/pillar3/{cms1,cms2}.pyas static Basel-3.1-only specs (the Art. 92(3A) internal-model gate — verified against the PS1/26 Annex II instructions, which have no CRR heritage), and the router's per-approach frame plumbing they consumed is retired. The recorded fix: the modelled-vs-standardised split now uses the explicit origin-approach complement ("standardised", "equity") — "exposures calculated according to the SA for credit risk include equity exposures subject to the IRB Equity Transitional" — where the retired CMS2 added only thestandardisedapproach's RWA to its per-class totals, leaving its "Subordinated debt, equity and other own funds" row at 0.0 against a 2.5M equity book and its Total exactly 2.5M short of CMS1's on the reference portfolio (CMS1 already used the complement and is unchanged). CMS2 column c is now simply the row's total actual RWA across all approaches; the golden diff is those two cells, and a new unit test pins the CMS1==CMS2 total reconciliation. Also recorded: CMS2 rows key the origination class (the CR6-A pattern — column b is the SA recomputation "of exposures reported in column (a)", the same population, so substitution never moves a row; unit-pinned with a two-leg fixture); columns b/d read the pre-supporting-factorsa_rwaS-TREA carrier (the engine's floor convention — the floor's fallback-path post-factor divergence is a recorded follow-up); the F-IRB/A-IRB sub-row shapes and the recorded-null purchased-receivables/IPRE-HVCRE sub-rows are preserved verbatim. CMS unit coverage moved totests/unit/reporting/pillar3/test_cms.py. Ref: PRA PS1/26 Art. 456(1)(a)/(b), Art. 2a; PS1/26 Annex II (UKB CMS1/CMS2 instructions). - Pillar 3 CR9/CR9.1/CR10 are declarative, closing the F3 class-basis decision — and defaulted obligors can no longer escape CR9's 100% PD band (Phase 7 S8). The three imperative bodies (the per-leaf CR9 loop with its six value helpers and mixed-type schemas, the per-grade CR9.1 loop, the four CR10 helpers) are deleted; the templates live in
reporting/pillar3/{cr9,cr10}.pyand run through the one executor. CR9's sheets key the obligor basis —reporting_class_origin×reporting_approach_origin, refined by the Annex XXII leaf taxonomy whose SME/property/financial-large discriminators stay a module-owned typed expression ported verbatim (including the absent-column degradation rules) — because the instructions bar substitution effects from back-testing sheets; CR10 has no class axis at all (supervisory categories ×sl_type), which closes F3: every Pillar 3 class key now reads a recorded ledger basis. The port is exactly number-neutral (the golden gate passed without regeneration). One recorded fix rides along, unit-pinned with zero golden impact: CR9's band allocation now forces defaulted obligors into the 100% band via a derived allocation column ("All defaulted exposures shall be included in the bucket representing PD of 100%") — the retired code bucketed them at model PD and was saved only by fixtures that happened to carry PD=1.0. Preserved verbatim as recorded: the sparse-row emission (only populated PD bands plus Total), the single-run point-in-time proxies for the obligor/default-rate columns including their live carrier ladders (prior_year_obligor_count,historical_annual_default_rate— the true prior-period/five-year series are a recorded follow-up), CR9.1's silent-empty gate on the never-produced ECAI scope columns, the CRR IPRE+HVCRE merge and equity force-emit in CR10, and CR10's fixed Art. 153(5) risk-weight column (a module post-step, populated even on empty categories). The Basel-3.1-only gate stays; the CRR heritage UK CR9/CR9.1 (coarser taxonomy) is a recorded scope-out. Vocabulary grewMean.scale; CR9.1's ECAI-grade rows are the estate's one data-driven row axis (a generate-time spec). Unit coverage moved totests/unit/reporting/pillar3/test_cr9_cr10.pywith new obligor-basis substitution and defaulted-band pins. Ref: PRA PS1/26 Art. 452(h), Art. 180(1)(f), Annex XXII paras 12-15; CRR Art. 438(e), Art. 153(5), Art. 155(2); PS1/26 Annex XXIV. - Pillar 3 CR6/CR6-A/CR7/CR7-A are declarative, on the obligor class basis the IRB disclosure instructions mandate — and two latent defects are fixed: the CRR CR7 "Retail — Secured by immovable property" row now actually sums the A-IRB mortgage class, and defaulted IRB exposures now always land in CR6's 100% PD band (Phase 7 S8, F3 second tranche). The four imperative bodies (per-class CR6 loop, the 14 CR7 row handlers, the CR6-A/CR7-A value computations) are deleted; each template lives in
reporting/pillar3/{cr6,cr6a,cr7,cr7a}.pyand runs through the one executor — CR6 as one spec per obligor-class sheet (the PD-range label stays a String column via a module-owned post-step), CR7-A as one spec per origin approach. The class basis is the OPPOSITE of the CR4/CR5 tranche, because the instructions say so verbatim: CR6 allocates each obligor's exposures "without considering any substitution effects due to CRM" and CR7-A discloses "in accordance with the exposure class applicable to the obligor" — so CR6 sheets and CR7/CR7-A rows keyreporting_class_origin×reporting_approach_origin, with substitution carried by the column pairs (CR7 a→b, CR7-A m→n), never a sheet move; CR6-A keys the origination class (its Art. 147-shaped row axis has no defaulted sink). Number-neutral on the reference portfolio except one recorded fix: the CRR CR7 row 8 handler summed(retail_other, retail_qrre)— byte-identical to row 9 and contradicting its own label — and now sumsretail_mortgage(golden row 8 flips to null; the portfolio has no A-IRB mortgage). The second fix is invisible to the goldens but pinned by a new unit test: the engine's defaulted-IRB treatment overrides RWA but never the model PD, so CR6's band allocation previously bucketed a defaulted obligor at its model PD; the derived allocation column now forces defaulted rows to the 100% band ("All defaulted exposures shall be included in the bucket representing PD of 100%"). Preserved verbatim as recorded: the Basel 3.1 pre-input-floor allocation / post-floor reporting PD split (PS1/26 Annex XXII mandates both sides — allocate onpd, reportpd_floored/lgd_floored×100), the CR7 a==b / CR7-A m==n approximations (the hypothetical pre-credit-derivative and no-substitution RWEAs need engine carriers — recorded follow-ups), CR6's permanently-null provisions column, and CR6-A's constant-zero roll-out column. Unit coverage moved totests/unit/reporting/pillar3/test_cr6_cr7.py, including new obligor-basis substitution pins (both legs of a guaranteed IRB exposure stay on the obligor's sheet). Ref: CRR Art. 452(b)/(g), 453(g)/(j); PRA PS1/26 Annex XXII; COREP Annex II C 08.01/C 08.03. - Pillar 3 CR4/CR5 are declarative, and their class rows finally report the applied/substituted Art. 112 class — a defaulted SA exposure now appears in "Exposures in default" instead of hiding in its origination class row (Phase 7 S8, recorded decision F3 first tranche). The imperative
_compute_cr4_values/_compute_cr5_values/_cr5_row_predicatebodies are deleted; both templates now live as cell specifications inreporting/pillar3/cr4.py/cr5.pyand run through the one executor over the full sealed ledger. The number-changing part is the class basis: the retired code keyed every CR4/CR5 row on the classifier's rawexposure_class, which is neither of the two bases the COREP C 07.00 heritage mandates (Annex II ¶56/¶56A: first-step obligor assignment, second-step substitution reallocation; EBA Q&A 2018_4093) — the UK/UKB Annex XX disclosure instructions are silent, so the COREP mapping decides. Now CR4 columns a/b ("before CCF/CF and CRM") key onreporting_class_origin(the obligor's applied class, C 07.00 col 0010 basis), while CR4 columns c/d/e/f and every CR5 figure key onreporting_class(post-substitution, C 07.00 col 0200 basis — the covered leg of a guaranteed exposure reports in the protection provider's row, at the substituted risk weight in CR5's bands). On the reference portfolio the entire golden diff is one exposure: the defaulted corporate (EAD 1.0M, RW 150%) moves from row 7 "Corporates" to row 10 "Exposures in default" in both templates under both regimes; grand totals are unchanged. Also recorded: CR5's "Of which: unrated" provably equals the Total (thesa_cqsread was dead — the engine never produces it and the output seal strips undeclared columns; an engine rating-presence column is the F6 fix path); the Art. 123B currency-mismatch pre-multiplier banding is a typed transform owned bycr5.py; and the B31 9f/9g split-leg role lists are preserved verbatim, with their known coverage gaps (mixed-collateralsecured_rre/secured_creand Art. 124H(3)wholelegs; residual-leg dual membership) recorded as follow-ups in the Phase 7 plan rather than silently changed. Vocabulary grew three primitives sized to this family:SafeSum(multi-column gross sums), presence-tolerant half-openbetweenbands, andany_ofpredicate unions. Unit coverage moved totests/unit/reporting/pillar3/test_cr4_cr5.py, including new substitution and defaulted-mover tests pinning the split basis. Ref: CRR Art. 444(e); PRA PS1/26 Annex XX; COREP Annex II C 07.00 ¶40-43/¶56/¶56A/¶65; CRR Art. 235/112(2); EBA Q&A 2018_4093. - Pillar 3 OV1 is declarative (Phase 7 S8). The overview-of-RWEAs template — totals, per-approach rows, the pre-floor row and capital-ratio rows, the 250%-RW memo, the Basel 3.1 equity sub-approach memo rows, the output-floor rows, and the 8% own-funds shim — now lives as cell specifications in
reporting/pillar3/ov1.pyand runs through the one executor; the imperative generator body and its ten helper functions are deleted. Three vocabulary verbs were added for it (SideContextfor out-of-frame scalars like OF-ADJ and the capital-ratio overrides,FirstNonNullfor the broadcast floor multiplier, and presence-tolerantequalspredicates for the equity sub-approach discriminators the output seal strips today — recorded permanently-null cells). Behaviour-identical: the per-approach rows key on the recorded pre-substitution basis (reporting_approach_origin=approach_applied), all Pillar 3 unit tests and the OV1 goldens pass unchanged. - The declarative reporting executor exists, and the first template runs through it (Phase 7 S7).
reporting/cellspec.pydefines the closed cell vocabulary (Sum,Mean,WeightedAvg,Ratio,Count,PriorPeriod,Formula), row predicates over the canonical reporting-ledger columns, and oneexecute()that turns aTemplateSpec(the frozen row/column layout constants paired with per-cell bindings) into a template DataFrame — with the COREP-zero vs Pillar-3-null empty-cell drift as a per-template policy, exactly two escape hatches (an intra-templateFormulaover already-computed cells, and theReportingContextside inputs), and no expression DSL. Pillar 3 CR8 (the IRB RWEA flow statement) is the pilot: its cell semantics now live inreporting/pillar3/cr8.py::CR8_SPECand the generator method is a thin dispatch-router delegation — all existing CR8 tests and goldens pass unchanged. Remaining templates migrate family-by-family per the Phase 7 plan. Pinned bytests/unit/reporting/test_cellspec.py. - The rulepack now carries the regime's reporting template inventory (Phase 7 S6). A new cited
ReportingTemplateSetrule shape names which COREP and Pillar 3 template families each regime's reporting framework comprises, plus the variant token that will select each template's regime-specific layout in the declarative reporting layer: the CRR entry is the Reg (EU) 2021/451 Annex I COREP CR/CCR set + the Part Eight Pillar 3 set (cited CRR Art. 430); the Basel 3.1 entry addsOF 02.01(output floor) andCMS1/CMS2(cited PS1/26). The entry is content-hashed like every pack value — changing a regime's template set is a new pack version — and is readable viaresolve(regime, date).reporting(). A typedReportingContext(reporting/metadata.py) pairs the resolved set with the out-of-frame side inputs templates need beyond the sealed results ledger (output-floor summary, prior-period results, capital-ratio overrides, reporting-basis/institution-type elections). Nothing consumes the metadata yet — the generators still key on framework strings until the per-template strangler slices land — so this is behaviour-neutral (all goldens unchanged). Pinned bytests/unit/rulebook/test_reporting_metadata.py.
Changed¶
- COREP now reads the sealed reporting ledger end-to-end — the number-neutral convergence completion (Phase 7). C 07.00's sheet key collapsed from the
exposure_class_applied/exposure_classladder to the single sealedreporting_class_origin, and its population filters SA onreporting_approach_origin; C 08.01–05's per-class sheet keys, the IRB population (_irb_population/_non_slotting), C 08.06's slotting population and C 08.01's approach predicates all key the sealed origin names; C 09.02's class predicates and approach picks likewise. C 08.07 alone deliberately keeps the rawexposure_classsheet key — its rows are the Art. 147 origination taxonomy over the full population, which has no "defaulted" class. Attribute/derived reads (SMEstr.containsfallbacks, defaulted ladders, substitution-inflow class columns) stay raw by recorded decision. Zero golden changes; no regen. The per-class sheet partitions now drop null class keys (a sealed frame always carries the column; a sourceless typed-null row belongs to no sheet), and the remaining 14 COREP unit files moved ontoLedgerShimCorepGeneratorso the unit estate exercises the production input contract. The test shims'__new__return types are now annotated, clearing the shim-typing diagnostic family repo-wide (ty 125 → 6). - The never-populated
cached_pathfield was removed fromValidationResponseand thePOST /api/validateJSON response — it was a vestige of an earlier caching idea, superseded by the calculation run index above. - Two dead output columns were removed from the results frame (Phase 7 S3):
exposure_class_for_saandsubstitute_rw.exposure_class_for_sawas computed by the classifier (defaulted-priority + SL→corporate variant of the SA class) and carried across five stage edges, but nothing insrc/ever read it — it was superseded by the aggregator-computedexposure_class_applied(now also aliased asreporting_class_origin), which additionally handles SME-managed-as-retail and row-level defaults; its defaulted-priority and SL semantics remain pinned at the aggregator (tests/unit/test_exposure_class_applied.py) and on the liveexposure_class_sacolumn.substitute_rwwas initialised to a null literal in the CRM EAD setup and never written again — the SA path writes substitution intorisk_weight/guarantor_rw— so every persisted value was null. Consumers of the results parquet that referenced either column (there are none known) should readreporting_class_origin/guarantor_rwinstead. Cell-neutral: all 95 reporting goldens structure-identical; full suite green.
Fixed¶
- The equity IRB-Simple path now emits the Art. 158(7) expected-loss amount — computed as
EL rate × exposure valueand paired with the Art. 155(2) simple risk-weight bucket (P1.247, CRR Art. 158(7)). Under the Art. 155(2) simple risk-weight approach the engine assigned only arisk_weight(190%/290%/370%) and_calculate_rwaemitted onlyrwa/rwa_final— noexpected_losscolumn was ever produced on the simple path (the only equity EL was on the Art. 155(3) PD/LGD branch). The Art. 158(7) EL amount — a required computation, disclosed in the COREP C 08 / Pillar 3 IRB expected-loss columns — was therefore silently zero for every equity IRB-Simple exposure. The simple path now emitsexpected_loss = EL rate × ead_final, with the EL rate read from a new cited rulepack entryequity_irb_simple_el(packs/crr.py,Citation("CRR", "158(7)")) and routed by a when-chain that shares its bucket predicates with the RW chain, so each rate pairs with its weight: 0.8% for private equity in sufficiently diversified portfolios (190%) and exchange-traded/listed equity (290%), 2.4% for all other equity (370%), and 0.0% for central-bank equity (0%). Regulatory scope — equity EL is not fed to the Art. 159 comparison. UK CRR Art. 159 (crr.pdf p.155, verbatim) subtracts only the "Article 158(5), (6) and (10)" EL amounts from provisions, and Art. 36(1)(d) (PRA Rulebook Own Funds Part) deducts only that Art. 159 negative amount; Art. 158(7)/(8)/(9) equity EL is outside both, and Art. 155(2) sets equity RWA =RW × exposure valuewith no EL gross-up (crr.pdf p.152). The emitted amount is therefore a disclosure quantity only — the aggregator continues to feedcompute_el_portfolio_summarythe IRB and slotting frames alone (equity is never pooled), so the EL-vs-provisions shortfall/excess and the CET1/T2 figures are unchanged. Regime scoping: confined to the CRR IRB-Simple path by the existingequity_irb_approaches_availablefeature — under Basel 3.1 all equity routes to SA (IRB equity removed) and the simple EL is never emitted; the CRR SA path (Art. 133) likewise emits none. Worked cases: exchange-traded EAD £200,000 → EL = 0.008 × 200,000 = £1,600 (RW 290%); other equity EAD £100,000 → EL = 0.024 × 100,000 = £2,400 (RW 370%); with an IRB corporate book of EL £50,000 vs provisions £30,000 the Art. 159 shortfall stays £20,000 — the equity £2,400 is not added. Number-neutral across every existing portfolio: the full unit + acceptance + integration + contracts + oracle suite is green with no golden, oracle or acceptance figure moved (no capital or reporting cell consumes equity EL — CR10's equity sheet keys the slotting-origin population, which equity rows never enter). Pinned bytests/unit/crr/test_p1_247_equity_simple_el.py(per-bucket EL rates, EL↔RW pairing, central-bank zero, EAD scaling, the SA/Basel-3.1 no-emission controls, and the pack-entry check) and the CRR acceptance twintests/acceptance/crr/test_p1_247_art_158_7_equity_simple_el.py(end-to-end EL emission plus the Art. 159-exclusion guard: an equity simple row alongside a corporate IRB book leaves the shortfall unmoved). Ref: CRR Art. 158(7) (PRA Rulebook (CRR Firms); Art. 158 omitted from onshored CRR by SI 2021/1078); Art. 159; Art. 155(2); Art. 36(1)(d). - The large-corporate F-IRB-only subclass test now measures revenue at the highest level of consolidation — a small subsidiary of a > GBP 440m group is F-IRB-only instead of keeping A-IRB (P1.245, PS1/26 Art. 147(4C)(b)(ii) with Art. 147A(1)(e)). PS1/26 Art. 147(4C)(b)(ii) assigns a corporate to the financial-/large-corporates subclass — restricted to F-IRB only by Art. 147A(1)(e)(ii) — when its annual revenue exceeds GBP 440m "taken at the highest level of consolidation which is performed and at which audited financial statements are available … the average annual amount over the last three years".
engine/stages/classify/approach.pycompared the counterparty's own point-in-timecp_annual_revenueagainst the threshold with no group roll-up, so a small subsidiary of a large group kept A-IRB where PS1/26 mandates F-IRB — an RWA understatement (own-estimate A-IRB LGD is typically below the F-IRB supervisory LGD). The classifier now rolls revenue up the resolved counterparty hierarchy: a newattributes.with_group_annual_revenuederivescp_group_annual_revenue= max(the counterparty's ownannual_revenue, itsultimate_parent_reference's ownannual_revenue) via a self-join on the sealed counterparty lookup — a parent's ownannual_revenueis, by convention, its consolidated audited-accounts turnover, so the ultimate parent (the top of the group) carries the highest-consolidation figure. The large-corp gate in_apply_b31_approach_restrictionsreadscp_group_annual_revenue; the entity-levelcp_annual_revenueis deliberately left untouched for the Art. 4(1)(128D) SME size test and the Art. 501 supporting factor, which correctly key off the entity's own turnover.max(not a source-preference coalesce) is the conservative direction for a test that forces F-IRB — neither a small subsidiary figure nor a data-anomalous small parent figure can let a large obligor escape. Null composition:max_horizontalignores nulls, so a null own turnover under a revenue-bearing parent yields the parent figure (the subsidiary is resolved and no longer trips the CLS008 conservative-large warning); a standalone corporate (nullultimate_parent_reference) yields its own; both-null falls through to the pre-existing conservative-large default (CLS008). A new CLS011 classification warning records a roll-up-driven flip — own turnover at/below GBP 440m but group turnover above it — for audit transparency (mirroring CLS010); CLS008 and CLS011 are mutually exclusive, and CLS008's filter was re-keyed onto the group figure. 3-year averaging (Art. 147(4C)(b)(ii) second sentence) is a documented deferral: the schema carries a single point-in-timeannual_revenue, so the most-recent-figure convention is used; multi-year revenue inputs are a future schema enablement. CRR has no such subclass — the whole branch (and roll-up) is gated on theapproach_restrictions_b31_applicablefeature and is a no-op under CRR, so noconfig.is_crrbranch is introduced. Worked case — a subsidiary with £50m own revenue under a £500m parent, £1m senior corporate loan at PD 1% / modelled LGD 35% / M 3y with corporate A-IRB+F-IRB model permission: under Basel 3.1 it flips A-IRB (LGD 35%, RW 0.7225, RWA £722,482) → F-IRB (supervisory LGD 40%, RW 0.8257, RWA £825,694, +14.3%); the null-own-revenue subsidiary under the same parent flips identically; the small-group control (£50m sub under a £50m parent) stays A-IRB (RWA £722,482); a standalone £500m corporate is F-IRB regardless (own-large branch unchanged). Under CRR all four stay A-IRB (RW 0.8177 — higher than the B31 A-IRB weight because CRR retains the 1.06 scaling factor). The roll-up is proved end-to-end (the hierarchy stage resolves each subsidiary's ultimate parent fromorg_mappings, then the classifier rolls group revenue up that chain). Number-neutral across every existing portfolio: the full unit + acceptance + integration + contracts + oracle suite is green with no golden, oracle or acceptance figure moved — no existing fixture carried a corporate under a revenue-bearing parent (viaorg_mappings) that was routed to A-IRB under Basel 3.1. Pinned bytests/unit/classifier/test_p1_245_group_revenue_rollup.py(roll-up flip, null-own-under-large-parent, small-group and standalone controls, LGD clearing, the CRR no-subclass control, and CLS011 emission + its negative controls + CLS008 composition) and the CRR/Basel 3.1 acceptance twinstests/acceptance/basel31/test_p1_245_group_revenue_rollup.py(all four scenarios end-to-end through the full pipeline, and the flip-raises-RWA check). Ref: PRA PS1/26 Art. 147(4C)(b)(ii); Art. 147A(1)(e); Art. 4(1)(128D) (SME size, own turnover); Art. 501 (SME supporting factor, own turnover). - A revolving retail exposure is now admitted to the QRRE sub-class only when it is to an individual, unsecured, and — to the extent undrawn — unconditionally cancellable; a secured or non-cancellable revolving retail line is demoted to RETAIL_OTHER (P1.244, CRR Art. 154(4)(a)-(b); PS1/26 Art. 147(5A)(a)-(b)). CRR Art. 154(4)(a)-(c) and PS1/26 Art. 147(5A)(a)-(c) restrict the qualifying revolving retail exposures (QRRE) sub-class to exposures that are (a) to individuals and (b) revolving, UNSECURED, and — to the extent they are not drawn — immediately and unconditionally cancellable; (c) is the per-individual GBP 90,000 (B31) / EUR 100,000 (CRR) aggregate-nominal limit.
engine/stages/classify/subtypes.pytested only the revolving flag and the (c) aggregate limit, so a secured revolving retail facility, a non-cancellable undrawn commitment, or an SME-derived / non-natural-personRETAIL_OTHERrow could wrongly become QRRE — an RWA understatement, because QRRE's fixed 0.04 asset correlation is below the retail-other correlation at the low PDs typical of performing revolving retail (the two cross at PD ≈ 7.3%).is_qrre_candidatenow carries three added gates: (a) individuals — the sharednatural_person_expr()predicate (is_natural_personflag OR theindividual/natural_person/retailentity-type aliases; a null flag AND non-natural type → NOT an individual → not QRRE, the same conservative signal P1.243 introduced); (b) unsecured — a new nullable facility-level attestationis_secured(FACILITY_SCHEMA), coupled through the existing_FACILITY_QRRE_COUPLED_COLUMNSmachinery so it reaches both the drawn loan exposures and the synthesisedfacility_undrawnrows, consumed as~is_secured(qrre_unsecured_expr); (b) cancellable — the row's undrawn commitment must carry the CCF unconditionally-cancellable (LR / low-risk)risk_type(qrre_undrawn_cancellable_expr, reusing the CCF machinery's UC signal rather than minting a duplicate flag), while a fully-drawn row (no undrawn commitment) satisfies the limb trivially. Design decisions & direction of error: the classifier runs before CRMProcessor, so general (non-property) collateral is not yet allocated — an attestation was chosen over a classify-time pledge-presence join, which would have had to replicate CRM's multi-level (direct/facility/counterparty) beneficiary cascade (recorded deferral).is_secureddefaults False (unsecured) — the backward-compatible direction consistent with the pipeline's treatment of absent collateral everywhere else and with revolving retail credit being unsecured by nature; the Art. 147(5A) second-sub-paragraph wage-account derogation (a wage-account-linked collateralised facility is treated as unsecured) is applied via input semantics (setis_secured=False), a documented deferral for an explicit flag. The cancellability limb reuses the CCF null convention with no divergence: a null/non-LRrisk_typeon an undrawn row resolves to not-cancellable (the CCF path likewise resolves a null risk_type to the MR-equivalent CCF, never the LR benefit) → not QRRE. The same conditions apply under both regimes (CRR Art. 154(4) is identical; only the (c) limit value differs, from the pack), so the gates are not regime-Featured — noconfig.is_crrbranch. A new CLS010 classification warning is raised once per run when a would-be QRRE row (RETAIL_OTHER, regulatory-retail, revolving) is denied by an (a)/(b) gate (limit-only demotions are not flagged — that is the long-standing (c) behaviour). Demoted rows land in RETAIL_OTHER (never mortgage, never expelled from retail). Worked case — a £50,000 individual revolving line at PD 2% / LGD 45% routed to retail A-IRB: as QRRE (R = 0.04) RWA = £15,329 (CRR) / £14,461 (B31); demoted to RETAIL_OTHER (R = 0.0946) RWA = £30,733 (CRR) / £28,993 (B31) — +100.5%, confirming the demotion is the conservative direction; a genuine unsecured, LR-cancellable individual control is unchanged (stays QRRE). Number-neutral across every existing portfolio: the full unit + acceptance + integration + contracts + oracle suite is green with no golden, oracle or acceptance figure moved — no existing fixture carried a secured or non-cancellable revolving retail line that classified as QRRE. The only fixture change is the P1.191 QRRE control facility, whoserisk_typeis set fromMRtoLR— it is a genuinely unconditionally-cancellable revolving line, so this is a correct attestation (its intent is unchanged QRRE). Theis_securedend-to-end facility→hierarchy→classify coupling is exercised by the raw-bundle tests. Pinned bytests/unit/classifier/test_p1_244_qrre_gates.py(unsecured, cancellable and individuals gates + controls under CRR/B31, CLS010 emission and its all-pass negative control, the fully-drawn trivial-cancellability case) and the CRR/Basel 3.1 acceptance twinstests/acceptance/basel31/test_p1_244_qrre_gates.py(all three gates end-to-end through the full pipeline). Ref: CRR Art. 154(4)(a)-(c); PRA PS1/26 Art. 147(5A)(a)-(c); CRR Art. 111(1) / PS1/26 Table A1 Row 7 (LR = unconditionally cancellable). - A natural person owing more than the retail cap now stays in the IRB retail class instead of being expelled to corporate — the EUR 1,000,000 / GBP 880,000 monetary cap conditions the SME limb ONLY (P1.243, CRR Art. 147(5)(a); PS1/26 Art. 147(5)(a)). CRR Art. 147(5)(a) and PS1/26 Art. 147(5)(a) admit an exposure to the IRB retail exposure class when it is either (i) an exposure to one or more natural persons — with no amount cap — or (ii) an exposure to an SME, provided the total amount owed (excluding residential-property-secured exposures) does not exceed EUR 1,000,000 (CRR) / GBP 880,000 (PS1/26). The engine applied the aggregate-owed threshold via
qualifies_as_retailto every row and reclassified any RETAIL_OTHER row that failed it — including natural persons — to CORPORATE (engine/stages/classify/subtypes.py), so a large-borrowing individual in an IRB retail book was expelled from retail IRB into corporate: an RWA overstatement (corporate correlation/LGD instead of the retail formula) and a mandatory-classification error, not an election. Thequalifies_as_retailflag is the SA regulatory-retail test (CRR Art. 123 / PS1/26 Art. 123A — a separate rule that legitimately caps natural persons for the 75% SA regulatory-retail treatment, and additionally, under B31, applies the Art. 123A(1)(b)(ii) 0.2% granularity limb); it is left completely untouched. The IRB divergence lives only inexposure_class_irb:sync_irb_exposure_classnow restores a natural person expelled to CORPORATE back to the IRB retail class, because neither the monetary cap (Art. 147(5)(a)(ii), SME limb only) nor the granularity limb (absent from Art. 147(5)) exists in the IRB retail class — provided the Art. 147(5)(c) management-basis condition holds (is_managed_as_retailnot explicitly False; a null flag defaults to True, matching the existing Art. 123A(1)(b)(iii) KEEP). Because the IRB calculator readsexposure_class(notexposure_class_irb) for correlation/LGD/floor selection,_align_irb_exposure_class(engine/stages/classify/approach.py) — previously scoped to rgla/pse rows — now also propagates the restored retail class toexposure_classfor the IRB-routed natural person, gated onexposure_class_irb != exposure_classso QRRE/mortgage/SME subtyping is never reverted. The natural-person signal is the explicitis_natural_personflag OR one of the documented natural-personentity_typealiases (individual/natural_person/retail, a newNATURAL_PERSON_ENTITY_TYPESinput-domain constant indata/schemas.py); a null flag AND a non-natural entity type resolve to NOT a natural person (conservative direction of error — the monetary cap keeps binding). The split is regime-invariant (both texts share the structure; only the cap value differs, and it comes from the pack), so noconfig.is_crrbranch is introduced. The SME limb is unchanged: an SME owing more than the cap stays corporate_sme. Worked case — a natural person owing £2,000,000 (> both caps) at PD 2% / own-LGD 45%, with retail A-IRB permission: exposure_class_irb → retail_other, routed to A-IRB, retail-other risk weight 0.6147 under CRR (K ≈ 0.04644 via retail correlation R ≈ 0.0946, maturity adjustment 1, × the 1.06 scaling factor) and 0.5799 under Basel 3.1 (no 1.06), materially below the corporate weight the buggy engine assigned; the SAqualifies_as_retailflag stays False (SA regulatory-retail correctly still expels the over-cap individual). Controls: an SME owing £2,000,000 stays corporate_sme; a natural person owing £500,000 (≤ cap) stays retail_other. Number-neutral across every existing portfolio: the full unit + acceptance + integration + contracts + oracle suite is green with no golden, oracle or acceptance figure moved — no existing fixture carried a natural person above the retail cap. Pinned bytests/unit/classifier/test_p1_243_natural_person_irb_retail_cap.py(CRR/B31 class + approach + SA-flag-untouched twins, SME and under-cap controls) and the CRR/Basel 3.1 acceptance twinstests/acceptance/basel31/test_p1_243_natural_person_irb_retail_cap.py(retail A-IRB routing + risk-weight pin, SME-stays-corporate control). Ref: CRR Art. 147(5)(a)(i)/(ii); PRA PS1/26 Art. 147(5)(a)(i)/(ii); Art. 123 / 123A (SA regulatory retail, untouched). - The 1.25× IRB correlation multiplier for large financial sector entities is now DERIVED from entity type + total assets, not just a user flag — a mandatory treatment the engine can no longer miss (P1.242 / P1.246, CRR Art. 142(1)(4) / Art. 153(2); PS1/26 IRB Part glossary + Art. 153(2)). CRR Art. 153(2) and PS1/26 Art. 153(2) both say the asset-value correlation R shall be multiplied by 1.25 for exposures to large financial sector entities (LFSEs) — a mandatory treatment, not an input election. The engine drove
requires_fi_scalarsolely from the user-suppliedapply_fi_scalarflag (engine/stages/classify/subtypes.py, docstring "user flag is authoritative … no entity-type gate"), so a firm that supplied the entity's FSE flag and total assets but forgot the election silently escaped the multiplier — an RWA understatement. The pack already declared the size test (lfse_total_assets_threshold) but nothing read it.classify_exposure_subtypesnow derivesrequires_fi_scalar = apply_fi_scalar OR (is_financial_sector_entity AND total_assets ≥ threshold), reading the threshold through the FX seam: CRR EUR 70bn (Art. 142(1)(4), individual/consolidated basis, most recent audited accounts) × the run's EUR/GBP rate → GBP; Basel 3.1 GBP 79bn native (PS1/26 IRB Part glossary "large financial sector entity", at the highest level of consolidation — the pack value was corrected from 0, and the CRR inline citation from the stale "Art. 4(1)(146)" to Art. 142(1)(4)).total_assetsis a GBP figure, mirroring the SME balance-sheet gate. The userapply_fi_scalaris retained as an authoritative True-override (a firm may know an entity is a large or unregulated FSE even when the data says otherwise) that can never suppress a derived True. Nulltotal_assetson a flagged FSE (direction-of-error: potential under-application, never silent): largeness is undetermined, so the multiplier is NOT applied — the LFSE base rate among FSEs is low (only the largest banks/insurers clear EUR 70bn / GBP 79bn), so defaulting unknown-size FSEs to "large" would over-state the whole FSE population — and a new CLS009 classification warning flags the data gap in both regimes (suppressed when an explicit election already resolves the treatment). The unregulated-FSE limb (Art. 142(1)(5) / 153(2), size-independent) is deferred: the schema carries no regulated-status signal, adding one is a schema-enablement change of its own, andapply_fi_scalaris the interim override for known unregulated FSEs. Worked case — a £1m senior IRB corporate loan to an FSE at PD 0.5% / LGD 45% / M 2.5: R rises 0.213456 → 0.266820 (×1.25), lifting RWA £737,884 → £965,199 (+30.8%) under CRR and £696,117 → £910,565 (+30.8%) under Basel 3.1; a £65bn FSE clears the CRR threshold but not the GBP 79bn PS1/26 one, so the multiplier applies under CRR only. Number-neutral across every existing portfolio: no fixture carries an FSE at or above the threshold, so no golden, oracle or acceptance figure moved (the only test change is the B31 threshold pin 0 → GBP 79bn). Pinned bytests/unit/classifier/test_p1_242_lfse_fi_scalar.py(CRR/B31 derivation twins, the regime-threshold divergence at £65bn, sub-threshold/non-FSE/null-assets no-scalar, CLS009 emission, the authoritative override, and the exact-1.25× correlation hand-calc). Ref: CRR Art. 142(1)(4)/(5), Art. 153(2); PRA PS1/26 IRB Part glossary, Art. 153(2). - A pledged life-insurance policy now takes the Art. 233(3) 8% FX reduction on a currency mismatch and can be recognised when pledged at facility or counterparty level, not only directly against one exposure (P1.275, Art. 232(3) with Art. 233(3)). The SA risk-weight-mapping path for pledged life-insurance policies (
engine/crm/life_insurance.py::compute_life_insurance_columns) had two gaps against Art. 232(3) (the secured portion of the exposure takes a risk weight mapped from the insurer's SA RW) read with Art. 233(3) (the protection value — the current surrender value — is reduced by the 8% FX volatility haircut when the policy is denominated in a different currency from the exposure). First, the value channel summed the raw surrender value with no currency comparison, so a USD policy securing a GBP exposure was recognised in full — an anti-conservative understatement (the comprehensive/LGD path already applied the 8% Hfx, but the RW-mapping side channel did not). Second, the pledge join matched onlybeneficiary_reference = exposure_reference, silently dropping any policy pledged at facility or counterparty level — a conservative loss of benefit. Both are fixed. The Art. 233(3) 8% reduction (the existing regime-invariantfx_haircutscalar in the common rulepack pack, cited CRR Art. 224 / retained unchanged by PS1/26) is applied per policy when its denomination —original_currencypre-FX-conversion, elsecurrency(the P1.135-safe pair, matchingengine/crm/haircuts.py) — differs from the exposure denomination; the mapped RW is unchanged, only the secured value shrinks. The pledge is now resolved at whichever level itsbeneficiary_referencenames via three chained left-joins onto the single exposures base (keeping the exposures plan single-referenced — no plan-node explosion): the direct level (weight 1.0), the facility level (parent_facility_reference) and the counterparty level (counterparty_reference), a facility/counterparty pledge shared pro-rata by EAD across that key's exposures (Art. 230-231 pooling). Reference namespaces are disjoint, so a key fires at exactly one level and a direct pledge still benefits only its own exposure; a life-insurance row still passes through the P1.270 own-issue gate. Null-currency decision (CONSERVATIVE): when the collateral carries a currency column but leaves it null, the match cannot be proven, so the 8% reduction is applied and oneCRM020data-quality warning is raised (the anti-conservative full-benefit treatment is disallowed); when the collateral carries no currency column at all the FX dimension is simply absent and no reduction applies — so every currency-matched or currency-agnostic fixture is number-neutral. Art. 232(3) and Art. 233(3) are retained unchanged under PS1/26 (the 8% Hfx is regime-invariant), so CRR and Basel 3.1 give identical results. Worked cases on a £1m unrated SA corporate loan (100% RW) secured by a £500k policy at insurer RW 20% → mapped secured RW 20%: currency-matched → secured 0.5 → blended 0.5×0.20 + 0.5×1.00 = 0.60 → RWA £600k; USD-denominated → 8% cut → effective £460k → blended 0.632 → RWA £632k; null currency → same £632k + one CRM020; a £1m GBP policy pledged at counterparty level over two loans (£600k + £400k) → shared pro-rata → each fully secured at 20% → total RWA £200k (vs £1,000k unprotected). The pack citation for the secured-RW map was corrected from Art. 232(1) to Art. 232(3). Pinned bytests/unit/crm/test_life_insurance.py(FX cut/match/null, original-currency-wins, facility + counterparty pro-rata, direct-pledge precedence, regime parity, CRM020) and the CRR/Basel 3.1 acceptance twinstest_p1_275_art_232_life_insurance.py(matched baseline, FX 8% cut, null-currency CRM020, counterparty pro-rata). Ref: CRR/PS1-26 Art. 232(3); Art. 233(3); Art. 224 (Hfx); Art. 230-231 (pooling); Art. 122. - A credit-linked note is now treated as cash collateral only when it is attested issued by the lending institution itself — a third-party CLN no longer gets full own-issue cash treatment (P1.274, Art. 218). CRR Art. 218 (retained verbatim by PS1/26 — regime-identical) grants cash-collateral treatment (0% haircut, full EAD/LGD* offset) to investments in credit-linked notes issued by the lending institution (the note's cash proceeds fund the protection), provided the embedded credit default swap qualifies as eligible unfunded protection.
engine/crm/haircuts.py::_normalize_collateral_type_exprmapped everycredit_linked_noterow to the cash bucket with no issuer check, so a third-party CLN — whose value is materially correlated with its reference entity (typically the obligor, the Art. 194(4) wrong-way-risk case) — received full own-issue cash treatment (an anti-conservative understatement). A new nullableCOLLATERAL_SCHEMAfieldis_own_issued_clngates the treatment: a CLN that is not attested own-issued (is_own_issued_clnFalse or null) is ruled ineligible funded protection — itsvalue_after_haircutis zeroed,is_eligible_financial_collateralis cleared, and oneCRM019data-quality warning is raised. Because the F-IRBeffectively_securedamount derives from the zeroed value, the gate removes the row from both the SA E* reduction and the F-IRB LGD* input in one place. Nullis_own_issued_clnis CONSERVATIVE = own-issuance unattested → ineligible (absence of attestation must not fabricate cash treatment; no Boolean default, mirroring theis_main_index/is_listedidiom). The gate is eligibility, not valuation — a single shared predicate (engine/crm/haircuts.py::credit_linked_note_ineligible_expr) drives both the value zeroing inapply_haircutsand the CRM019 emission inapply_collateral, so the two cannot drift, exactly as the P1.271 equity gate does; the 0% cash haircut the row would otherwise take is left intact. Fallback decision: a non-own-issued CLN degrades to ineligible (not to a debt-security-of-its-issuer treatment) — a CLN carries no clean issuer-bond eligibility path in_normalize_collateral_type_expr, and recognising it as a plain bond of its issuer would ignore the Art. 194(4) reference-entity correlation, so ineligible is the smallest unambiguously conservative mapping. Worked cases on a £1m unrated SA corporate loan (100% RW) secured by £500k of credit-linked note collateral (identical under both regimes — Art. 218 and the unrated-corporate 100% RW are regime-invariant):is_own_issued_cln=True→ cash treatment → EAD/RWA fall to £500k;is_own_issued_clnunattested → ineligible → RWA stays at the £1m gross + one CRM019 warning. Every existing portfolio is number-neutral: no golden or oracle fixture carriescredit_linked_notecollateral, and the three existing CLN unit tests exercise the type→financial primitives (collateral_category_expr,collateral_lgd_expr,calculate_single_haircut) which remain correct for own-issued notes. Pinned bytests/unit/crm/test_p1_274_credit_linked_note_own_issue.py(own-issued cash treatment, null/False ineligibility, predicate + no-op-when-column-absent) and the CRR/Basel 3.1 acceptance twinstest_p1_274_art_218_credit_linked_note.py(own-issued control, third-party no-benefit, CRM019, own-issue-flag-alone-moves-RWA). Ref: CRR/PS1-26 Art. 218; Art. 194(4); Art. 122. - Finance-lease exposures can now be treated as collateralised by the leased asset — a lessor F-IRB book gets the leased-asset-secured LGD instead of the flat unsecured supervisory value (P1.273, Art. 199(7) with Art. 211). CRR Art. 199(7) (and PS1/26 Art. 199(7), both retaining Art. 211 verbatim) allow an exposure arising from a leasing transaction to be "treated in the same manner as loans collateralised by the type of property leased" once the Art. 211 conditions are met. No lease-as-collateral path existed: the FIRB Foundation Collateral Method already recognised real-estate / other-physical collateral (gated by the P1.235
is_eligible_irb_collateralattestation), but nothing let a lessor represent the leased asset, so a lessor portfolio received the flat senior unsecured LGD (45% CRR / 40% B31) — a conservative over-statement. The remedy is a documented input convention plus an Art. 211 attestation flag: the leased asset is supplied as an ordinary non-financial collateral row (collateral_type=real_estatefor property leases,other_physicalfor equipment/plant/vehicle leases) pledged to the lease exposure, and the lessor attests the lease-specific Art. 211 conditions via a new nullableCOLLATERAL_SCHEMAfieldis_lease_collateral_attested. That attestation is an independent recognition route through the existing FCM — Art. 211(a) folds in the Art. 208/210 property-eligibility thatis_eligible_irb_collateralotherwise attests, so attesting Art. 211 attests a superset — and it is OR-ed into theengine/crm/collateral.py::_apply_collateral_unifiedeligibility gate: a lessor row is recognised without the general IRB flag, while all existing (non-lease) collateral is untouched. The Art. 211 conditions handled: (a) property-type eligibility (Art. 208/210) — subsumed; (b) robust lessor risk management, (c) legal ownership and timely enforcement, (d) unamortised-amount-vs-market-value gap — attested by the flag. Nullis_lease_collateral_attestedis CONSERVATIVE = not a lease-collateralised row / no attestation supplied → False (absence must not fabricate a lease CRM benefit; no Boolean default, mirroring theis_main_index/is_listedidiom), so every existing portfolio is number-neutral (goldens and oracle unchanged). Consulted only for non-financial collateral. Worked case (£10m senior corporate F-IRB lease, £10m leased asset,is_eligible_irb_collateral=False): attestedother_physical→ LGD* = 0.428571 under CRR (Art. 230(2) other-physical FCM haircut 40% → £6m; OC=1.4× → effectively-secured £4.285714m; LGDS 40% blended with LGDU 45% over the secured/unsecured split) and 0.31 under B31 (LGDU 40%), the secured LGD flowing through torwa_final; unattested → LGD reverts to LGDU 0.45 / 0.40 with oneCRM014warning (the pre-P1.273 behaviour). Pinned bytests/unit/crm/test_p1_273_lease_collateral.py(independent-route recognition, no-warning, real_estate + other_physical, general-IRB-route-unaffected, conservative-when-unattested) and the CRR/Basel 3.1 acceptance twinstest_p1_273_art_211_lease_collateral.py(LGD recognition, LGDU revert, attestation-lowers-RWA). Ref: CRR/PS1-26 Art. 199(7); Art. 211; Art. 208/210; Art. 230 (FCM); Art. 161(1)(a)/(aa) (LGDU). - On-balance-sheet netting cash collateral now takes the full Art. 237-239 maturity-mismatch treatment — a deposit maturing before the netted loan is scaled or (below 3 months / under a 1-year original term) zeroed (P1.241, Art. 219 with Art. 237-238). CRR/PS1-26 Art. 219 treats a netted deposit as cash collateral, so the funded-protection maturity-mismatch rules (Art. 237-239) apply to it exactly as to any other funded protection.
engine/crm/collateral.py::generate_netting_collateralbuilt the synthetic cash collateral carrying the beneficiary loan'smaturity_date, a nullresidual_maturity_yearsand no original term;apply_maturity_mismatch(engine/crm/haircuts.py) then filled the null residual to 10 years, so the mismatch testcoll_maturity < exposure_maturitycould never fire for netting collateral — a 6-month deposit netting a 5-year loan was recognised with no adjustment. The synthetic row now carries the deposit's own maturity asmaturity_date, its residualtasresidual_maturity_years(threaded via the run'sreporting_date) and its original term asoriginal_maturity_years(derived frommaturity_date − value_date, the same convention asengine/sa/risk_weights.py/hierarchy/enrich.py). The downstream stage then, on a mismatch, applies(t − 0.25)/(T − 0.25)(Art. 238-239) or zeroes the protection whent < 0.25(Art. 237(1)) OR the original term< 1y(Art. 237(2)(a)) — the same gates real financial collateral already gets. Day-count alignment: the residualtuses the/365.25basis of the exposure-sideTinapply_maturity_mismatch, so a deposit and loan sharing a maturity date net in full with no phantom mismatch (the original term uses the engine's/365original-maturity convention). Pooling convention (conservative): when several deposits pool into one(agreement, currency, counterparty)row, the pool carries the earliest (minimum) deposit maturity (shortestt→ largest haircut) AND the minimum original term (most likely to trip the <1y gate) — the prudent single-value summary. Null handling: a null deposit maturity / noreporting_dateleaves the residual null (permissive — absent maturity cannot establish a mismatch, the convention ordinary financial collateral follows; NOT an anti-conservative fill — the 10-year downstream default is unchanged, just no longer fed a null when data is present); a null original term (novalue_date) likewise leaves the Art. 237(2)(a) gate permissive. Regime-identical (PS1/26 carries Art. 237/238/239 forward unchanged). Worked cases (SA, unrated corporate 100% RW, £1m loan, £200k deposit, reporting 2026-01-01): a 3-year-original / 6-month-residual deposit (t = 181/365.25 ≈ 0.49555) netting a 7-year loan (Tcapped at 5) scales the £200k by(0.49555 − 0.25)/(5 − 0.25) ≈ 0.05169→ recognised ≈ £10,339 → RWA ≈ £989,661; the same deposit with a 6-month original term (value_date = reporting) is a mismatch with original < 1y → Art. 237(2)(a) zeroes the protection → no benefit → RWA £1,000,000; a 6-year deposit netting a 5-year loan has no mismatch → full £200k nets → RWA £800,000. Every existing golden/oracle output is number-neutral: the shared loans fixture has no deposits, and the P1.238 fixture gives deposit and loan the same maturity — which now nets in exactly full (the aligned/365.25day-count removes the ~£50 residual-vs-/365.25phantom the naive/365derivation would have introduced), while the stress and model-permissions scenarios carry no netting. The change bites only when a short deposit nets a longer loan. Pinned bytests/unit/crm/test_collateral_submodules.py(maturity/residual/original carry, null-without-reporting-date, earliest-maturity + min-original pooling, Art. 237(1) sub-3-month netting-path zeroing) and the CRR/Basel 3.1 acceptance twinstest_p1_241_art_219_netting_maturity_mismatch.py(matched control, partial scaling, short-original zeroed). Ref: CRR/PS1-26 Art. 219; Art. 237(1)/(2)(a); Art. 238-239; Art. 122. - Non-main-index equity is now recognised as collateral only when it is attested listed on a recognised exchange — closing the Art. 198(1)(a) eligibility gate deferred by P1.237 (P1.271). CRR/PS1-26 Art. 197(1)(f) makes equities/convertible bonds included in a MAIN index eligible financial collateral under all CRM methods; Art. 198(1)(a) extends eligibility to non-main-index equities only where they are listed on a recognised exchange (and then only under the comprehensive method this calculator uses by default). Until now eligibility was governed solely by the input
is_eligible_financial_collateralflag — no expression read the index/listing status — so an unlisted (or listing-unreported) non-main-index equity was recognised at the 25%/30% other-listed haircut it should never have earned. A new nullableCOLLATERAL_SCHEMAfieldis_listeddrives the gate: a collateral row of equity type that is neither attested main-index nor attested listed (is_main_indexandis_listedboth False/unset) is ruled ineligible — itsvalue_after_haircutis zeroed,is_eligible_financial_collateralis cleared, and oneCRM018data-quality warning is raised. Nullis_listedis CONSERVATIVE = not listed → ineligible (absence of data must not fabricate eligibility; no Boolean default, mirroring the P1.237is_main_indexidiom). Crucially the gate is eligibility, not valuation: the 25%/30% Art. 224 Table 3/4 supervisory haircut is a valuation parameter and is left intact on the row — a single shared predicate (engine/crm/haircuts.py::non_main_index_equity_ineligible_expr) drives both the value zeroing inapply_haircutsand the CRM018 emission inapply_collateral, so the two cannot drift. Main-index equity (Art. 197(1)(f)) bypasses the listing test entirely. Worked cases on a £1m unrated SA corporate loan (100% RW) secured by £500k of non-main-index equity: attestedis_listed=True→ recognised at 25% CRR / 30% B31 → RWA £625k / £650k;is_listedunreported → ineligible → RWA stays at the £1m gross + one CRM018 warning. The two shared-fixture other-listed equity rows and the FTSE-100 main-index row are attestedis_listed=True(factually listed — intent-preserving), so all goldens and the oracle are unchanged. Scope note: the restriction correctly enforced is the Art. 198(1)(a) listing condition on the default comprehensive-method path — not an SFT/repo-only restriction (neither CRR Art. 198 nor BCBS CRE22.38 conditions non-main-index equity on repo-style transactions; the "only under the comprehensive method" condition is what the audit's "repo-only" shorthand referred to). Excluding listed non-main-index equity from the rarely-elected Financial Collateral Simple Method (Art. 198 additional collateral is comprehensive-method-only) remains a follow-up. Pinned bytests/unit/crm/test_p1_271_non_main_index_equity_eligibility.py(value zeroing, eligibility clearing, haircut-preserved-on-ineligible, main-index bypass) and the CRR/Basel 3.1 acceptance twinstest_p1_271_art_198_non_main_index_equity.py(listed control, unlisted no-benefit, CRM018, listing-flag-alone-moves-RWA). Ref: CRR/PS1-26 Art. 197(1)(f)/198(1)(a); Art. 224 Table 3/4; BCBS CRE22.37-38. - Cash on deposit with a THIRD-PARTY institution is now treated as a guarantee by that institution — its own risk weight substitutes on the covered part, instead of 0% own-bank cash (P1.239 + P1.240, Art. 200(a)/232(2)). Under CRR/PS1-26 Art. 200(a)/232(2) with Art. 212(1), cash pledged that is held at another institution is "other funded credit protection" treated as a guarantee by the holder — the covered part of the exposure takes the holder institution's own SA risk weight (≥20% for a rated bank), not a 0% haircut with full EAD offset.
engine/crm/haircuts.pymapped every cash/deposit row to the 0% cash bucket regardless of where it was held (understating RWA), andCOLLATERAL_SCHEMAhad no holder field. A new optionalheld_by_counterparty_referenceidentifies the holder; NULL is PERMISSIVE = own-bank deposit → the existing 0% cash treatment is preserved (the overwhelmingly common case, so every existing portfolio is number-neutral — goldens and oracle unchanged). When populated, the row is partitioned out of the ordinary collateral frame (engine/crm/third_party_deposit.py::split_third_party_deposits) so it contributes to NO cash-collateral value channel (SA E, F-IRB LGD), and the SA calculator blends the covered part at the holder's institution risk weight (engine/sa/rw_adjustments.py::apply_third_party_deposit_rw_mapping), derived from the deposit row'sissuer_cqsvia the Art. 120/121 (CRR) / Art. 120A ECRA (Basel 3.1) institution tables — a cash deposit is a claim on the institution holding it, so its issuer IS the holder. The blend is benefit-only-capped (Art. 232 protection can never increase RWA), so a holder RW ≥ the obligor's leaves the exposure unchanged. Worked cases on a £1m unrated corporate loan with a £400k deposit: own-bank → RWA £600k (0% cash, EAD reduced); held at a CQS2 institution → covered part at 50% CRR / 30% B31 ECRA → RWA £800k / £720k (full EAD, RW substituted); held at an unrated institution → £1,000k both regimes (CRR unrated institution = 100%; Basel 3.1 unrated = the CRE20.21 SCRA Grade-C 150% conservative fallback since the deposit carries no SCRA grade — both give no net benefit under the benefit-only cap, so a firm wanting benefit for an unrated-but-Grade-A holder must supply the holder's rating/CQS). The holder RW reuses the sharedbuild_institution_guarantor_rw_expr(single source of truth, correct SCRA fallback). Art. 232(2) applies only to deposits held by an institution: a populated holder whoseissuer_typeis not an institution is out of scope — no benefit (and still not own-bank 0% cash) + a CRM017 warning. Deferred (F-IRB): the holder-RW substitution has no clean F-IRB analogue this pass, so under F-IRB a third-party deposit is conservatively given NO CRM benefit (excluded from the LGD* collateral input rather than valued at 0% cash) and raises oneCRM017warning recording the pending substitution — a registered follow-up, P1.271-style. Pinned bytests/unit/crm/test_p1_239_third_party_deposit.py(F-IRB exclusion + CRM017) and the CRR/Basel 3.1 acceptance twinstest_p1_239_art_232_third_party_deposit.py(own-bank control, rated + unrated holder). Ref: CRR/PS1-26 Art. 200(a)/232(2) with Art. 212(1); Art. 120/120A. - On-balance-sheet netting is now limited to a single counterparty — a deposit no longer offsets a loan to a different counterparty under a shared agreement (P1.238, Art. 195). CRR/PS1-26 Art. 195 limits on-B/S netting to "mutual claims" / "reciprocal cash balances between the institution and the counterparty" — one counterparty.
engine/crm/collateral.py::generate_netting_collateralkeyed its netting pools on(netting_agreement_reference, currency)only and matched beneficiaries on the shared reference alone, so a group-level agreement reference spanning multiple counterparties let a credit balance from counterparty A net a loan to counterparty B (understating RWA) — the docstring even documented this cross-counterparty behaviour as intentional. Pools and the sibling join are now additionally keyed oncounterparty_reference: a deposit nets only same-counterparty loans under the agreement (still across facilities — the agreement remains the set-off boundary, the counterparty the Art. 195 eligibility boundary). Anetting_agreement_referencethat spans more than one counterparty (a deposit and a positive loan for different counterparties) raises oneCRM016data-quality warning naming the agreement, so the disallowed offset is visible rather than silent.counterparty_referenceis a core exposure column in production; a test-caller fallback treats its absence as a single counterparty (no-op). No existing golden or oracle case carried a cross-counterparty netting agreement, so all are number-neutral; the change is RWA-increasing only for genuinely cross-counterparty pools. Pinned bytests/unit/crm/test_netting.py::TestNettingByAgreementReference(cross-counterparty disallowed, same-counterparty still nets, CRM016). Ref: CRR/PS1-26 Art. 195; Art. 219. - The F-IRB Foundation Collateral Method LGD* now grosses the exposure up by its own volatility haircut — E(1+HE) instead of bare E (P1.272, Art. 230(1) / Art. 228(2)). The Art. 230(1) LGD formula divides by E(1+HE):
LGD* = LGDU·EU/(E(1+HE)) + LGDS·ES/(E(1+HE))withEU = E(1+HE) − ES(CRR Art. 228(2) equivalently embeds HE in E).engine/crm/collateral.py::_apply_collateral_unifiedcomputed(LGDS·min(C,E) + LGDU·max(0,E−C))/Eon bareead_for_crm, omitting the gross-up — the exposure volatility haircutHE(Art. 223(5)) was applied only in the SA E branch. HE is non-zero solely for SFT rows that lend out a debt security, so FIRB SFT-style exposures with collateral on the general CRM path got a slightly understated LGD (the unsecured-share denominator was too small). The exposure basis and the collateral cap inlgd_star_exprnow useE' = ead_for_crm × (1 + exposure_volatility_haircut)— reusing the samehe_factorthe SA branch already applies.he_factor == 1for every non-SFT row (HE = 0), so all lending goldens are bit-stable; the dedicated SFT-FCCM path (which emits E directly) is untouched. Worked case: a £10m FIRB senior corporate SFT lending a CQS1 gilt (HE = 2%×√(5/10) = 0.014142), secured by £6m cash → LGD rises from the pre-fix 0.18 to 0.183765 = 0.45×(10.141m − 6m)/10.141m. Pinned bytests/unit/crm/test_p1_272_fcm_lgd_star_he_grossup.py(SFT gross-up, anti-assert vs bare-E, non-SFT HE=0 control). Ref: PS1/26 Art. 230(1); CRR Art. 228(2) / 223(5). - Collateral issued by the obligor (or a member of the obligor's group) is now ineligible funded protection — the first Art. 194(4) connected-issuer gate (P1.270). CRR/PS1-26 Art. 194(4) admits funded protection only where its value is not materially positively correlated with the obligor's credit quality; the canonical ineligible case (BCBS CRE22) is a security ISSUED by the obligor pledged back as collateral — if the obligor defaults the security is worthless exactly when the protection is needed. The
COLLATERAL_SCHEMAcarriedissuer_type/issuer_cqsbut no issuer identity, so such collateral was priced normally through the Art. 224 haircut chain and granted full CRM benefit. A new optionalissuer_counterparty_referencefield identifies the security's issuer; a CRM-stage gate (engine/crm/processor.py::_apply_own_issue_collateral_gate, run before the haircut/allocation chain) resolves it against the obligor (the counterparty of the exposure the collateral secures, at any pledge level) and, via the counterparty hierarchy'sultimate_parent_mappings, the obligor's group. On a match the collateral row is removed (filtered rather than value-zeroed, which also side-steps the pledge-percentage re-resolution that would otherwise revive a zeroed row) and oneCRM015data-quality warning is raised. Nullissuer_counterparty_referenceis PERMISSIVE (issuer unknown / not an issued security, e.g. cash on deposit) — the gate never fires, so every existing portfolio is number-neutral (goldens and oracle unchanged). Worked case: a £10m unrated SA corporate loan secured by a £10m own-issued CQS1 corporate bond keeps its full £10m RWA (no benefit) instead of the ~£0.8m post-collateral value a third-party bond earns; identical mechanics under CRR and Basel 3.1. The group limb resolves membership through the shared ultimate parent (parent, sibling, or child of the obligor). Pinned bytests/unit/crm/test_p1_270_own_issue_collateral.py(self-issue, group-member, third-party control, null-permissive, CRM015) and the CRR/Basel 3.1 acceptance twinstest_p1_270_art_194_own_issue_collateral.py. Ref: CRR/PS1-26 Art. 194(4); BCBS CRE22. - Equity collateral of unknown index membership now takes the higher other-listed haircut, not the cheaper main-index one — closing an anti-conservative null default (P1.237 in full; P1.271 partially — see the deferral note below). The Art. 224 equity haircut lookup resolved a null
is_main_indextoTrue(engine/crm/haircuts.py), so equity collateral whose index membership was unreported received the 15% (CRR) / 20% (Basel 3.1) main-index haircut instead of 25% / 30% — 10 percentage points of over-recognition, contrary to the project's never-fill-anti-conservative rule and to CRR/PS1-26 Art. 197(1)(f) / 198(1)(a), under which only main-index equities earn the cheaper all-methods treatment (other-listed equities are repo-only at the higher haircut). The null resolution is flipped toFalse(other-listed): unknown membership must not fabricate main-index eligibility. Only explicitly attestedis_main_index=Trueequity now earns 15%/20%. TheCOLLATERAL_SCHEMAfield keeps no Boolean default (null propagates to the engine, which resolves it conservatively). The dedicated-column path is affected; the legacy fallback (nois_main_indexcolumn present, which resolves viais_eligible_financial_collateral) is unchanged. Test fixtures that intend main-index equity now attest it explicitly (theCollateralbuilder gained anis_main_indexfield; the shared collateral fixture's FTSE 100 row sets it True); the two other-listed equity rows correctly move to 25%/30%, which nudged one Basel 3.1 F-IRB provisions pool's EL-excess from 606,093.03 to 605,933.93 (an other-listed equity row on a subordinated obligor now takes the correct 30% haircut, raising its EAD and EL — the pre-fix figure encoded the anti-conservative default). Pinned bytests/unit/crm/test_equity_main_index.py(null now → other-listed) and the CRR/Basel 3.1 D3/D11 equity acceptance scenarios. Deferred (P1.271, Art. 198(1)(a)): the repo-only eligibility restriction for non-main-index equity is NOT yet enforced — eligibility remains governed by the inputis_eligible_financial_collateralflag, and the audit's prescribedis_listedflag (default False → ineligible) plus its DQ warning require new schema and are tracked as a follow-up in IMPLEMENTATION_PLAN.md, mirroring the P1.235 deferred-conditions disclosure. This entry closes the haircut-band default only. Ref: CRR/PS1-26 Art. 197(1)(f) / 198(1)(a); Art. 224 Table 3/4. - Non-financial collateral now only reduces F-IRB LGD where the firm attests it is IRB-eligible, and attested receivables are capped at a 1-year original maturity — the first Art. 199(2)/(5)/(6) Foundation Collateral Method eligibility gate (P1.235). The F-IRB Foundation Collateral Method (Art. 230 LGD substitution) recognised any real-estate / receivables / other-physical collateral posted against the exposure, regardless of whether it met the Art. 199 recognition conditions — so unattested collateral (and receivables of any tenor) wrongly pulled LGD below the supervisory unsecured value.
engine/crm/collateral.py::_apply_collateral_unifiednow gates theeffectively_securedamount that feeds the Art. 231 waterfall: non-financial collateral is zeroed unless the institution attests eligibility via the pre-existingis_eligible_irb_collateralflag (Art. 199(2)), and an attested receivable whoseoriginal_maturity_yearsis populated > 1 year is zeroed even so (Art. 199(5) — explicit data contradicting the attestation wins conservatively). Each zeroed row accumulates oneCRM014data-quality WARNING (never raised), distinguishing "not attested" from "receivables > 1 year". The flag is the attestation, so its default ofFalse/unset is treated as ineligible — the P1.10 new-field-permissive precedent deliberately does not apply — but a null* receivables original maturity is PERMISSIVE (the attestation covers the maturity condition; the Boolean> 1ytest, not the float column, is filled toFalseto avoid an anti-conservative float fill). Scope is strictly the F-IRB FCM non-financial recognition path: financial collateral (Art. 197), SA EAD reduction, and exposure classification are untouched, and every existing fixture that attests eligibility (e.g. P1.190) is number-neutral. Worked cases: an unattested £10m RE collateral on a £10m senior corporate reverts LGD to LGDU (0.45 CRR / 0.40 Basel 3.1) instead of the recognised 0.378571 / 0.2800; the attestation flag alone flips the outcome end-to-end in both regimes. The deferred Art. 199 sub-conditions (affiliated-party / sub-participation / credit-derivative-linked / securitisation-linked receivables exclusions; RE material-dependence pair; other-physical liquid-market / public-price / 70%-10% realisation attestations) each require new input fields and are tracked as a follow-up. Pinned bytests/unit/crm/test_p1_235_firb_fcm_eligibility_gate.py(attestation, receivables-maturity, CRM014) and the CRR/Basel 3.1 acceptance twinstest_p1_235_art_199_firb_fcm_eligibility.py. Ref: CRR/PS1-26 Art. 199(2)/(5)/(6); Art. 230 (FCM); Art. 161(1)(a)/(aa) (LGDU); BCBS CRE32. - Securitisation positions are now recognised as eligible financial collateral under Art. 197(1)(h), with the Art. 224 Table 1 securitisation supervisory haircut (P1.234). Previously a securitisation debt security posted as collateral had no branch — it fell to the flat 40% "other physical" haircut with no eligibility gate, over-stating the haircut on eligible positions and (worse) silently granting collateral benefit to resecuritisations and >100%-RW positions that Art. 197(1)(h) makes ineligible. Added: a
securitisationcollateral-normalisation branch inengine/crm/haircuts.py(keyed oncollateral_type/issuer_type); securitisation haircut rows in both rulebook packs pinned to the printed Art. 224 Table 1 securitisation column (CRR 3-band CQS1 2/8/16%, CQS2-3 4/12/24%; Basel 3.1 5-band CQS1 2/8/8/16/16%, CQS2-3 4/12/12/24/24% — note the Basel 3.1 figures are the table's own printed values, NOT double the corporate column); and an eligibility gate that zeroes value +is_eligible_financial_collateralwhen the position is a resecuritisation, its risk weight exceeds 100%, or its CQS is outside 1-3. Two new nullableCOLLATERAL_SCHEMAfields drive the gate:is_resecuritisation(default False) andsecuritisation_position_risk_weight(null is CONSERVATIVE — the RW<=100% test cannot be confirmed, so the position is treated as ineligible). Identical mechanics under CRR and Basel 3.1. - The Art. 114(4)/(7) 0% domestic-CGCB extension now requires the exposure to be BOTH denominated AND funded in the guarantor's domestic currency (P1.229, Art. 235(3)). The engine tested only the guarantee currency against the guarantor's country, so a loan funded in a foreign currency but guaranteed in the sovereign's domestic currency (e.g. a USD-funded, EUR-guaranteed loan from an EU sovereign) wrongly received 0% on the covered part instead of the sovereign's own CQS risk weight (a CQS-3 sovereign should give 50%, not 0%). A nullable
funding_currencyinput column was added to the loan/facility/contingent schemas and plumbed through the hierarchy-exit edge to the SA, IRB and CRM guarantee paths; the newfunding_currency_exprhelper ANDs the funding limb intobuild_domestic_cgcb_guarantor_expr. Null funding currency is PERMISSIVE — it falls back to the exposure's denomination currency, preserving existing datasets' 0% treatment (mirrors the Art. 237(2)(a) null fallback). Identical mechanics under CRR and Basel 3.1. - Guarantee maturity-mismatch now enforces the Art. 237(1) and Art. 237(2)(b) eligibility gates, not just the Art. 239(3) scaling (P1.231).
_apply_maturity_mismatch_to_guaranteespreviously only scaled coverage by (t-0.25)/(T-0.25); it now ZEROES coverage — mirroring the collateral sibling inengine/crm/haircuts.py— when (a) the guarantee's raw residual maturity is < 3 months AND shorter than the exposure (Art. 237(1); tested on the pre-floor residuals so a short exposure whose T also floors to 0.25 no longer masks the mismatch), or (b) the exposure is subject to the Art. 162(3) one-day IRB maturity floor (daily-margined repos/SFTs) and any maturity mismatch exists (Art. 237(2)(b)). Thehas_one_day_maturity_floorexposure flag is joined onto each guarantee row; null/absent is PERMISSIVE (treated as no floor). A null / join-miss exposure maturity is CONSERVATIVELY defaulted to a 5y exposure (aligning with the collateral twin) so both gates and the scaling still bind rather than silently keeping full coverage; the gate mismatch test compares the raw residuals so protection that outlives a sub-3-month exposure stays recognised. The Art. 239(3) scaling formula is otherwise unchanged. Identical under CRR and Basel 3.1. - The Art. 237(2)(a) >=1yr original-maturity guarantee test now binds only WHERE a maturity mismatch exists, not as an unconditional pre-filter (P1.232). Previously any guarantee with
original_maturity_years< 1 year was dropped before the pipeline ever checked for a maturity mismatch, so a matched (or protection-outlives-exposure) short-dated guarantee — common in trade finance — was discarded and the exposure over-stated at the borrower's own risk weight. The test is relocated from the unconditional pre-filter in_prepare_guaranteesinto_apply_maturity_mismatch_to_guaranteesand conditioned on the same raw-residual mismatch as the Art. 237(1)/(2)(b) gates (mirroring the collateral sibling inhaircuts.py): a <1yr-original guarantee is zeroed only when the protection is shorter than the exposure; a matched or longer guarantee is now recognised (guarantor RW on the covered part). Null original maturity stays PERMISSIVE (>= 1yr; P1.10), and the residual-vs-original distinction (P1.219) is preserved — the gate reads the original term, the scaling reads the residual. Direction: reduces RWA for matched short-dated guaranteed exposures. Identical under CRR and Basel 3.1. - The Art. 140(2)(b) 100% floor now binds on exposures that received their short-term CQS from the Art. 120(3)(c) obligor spillover (P1.225 co-fire fix). The spillover overwrites
cqs/has_short_term_ecaion an unrated leg, so the contamination predicate (cqsnull AND no ST ECAI) skipped exactly the legs the floor must catch — an obligor with a 50% short-term facility left its spilled sibling at 50% instead of ≥100% under both regimes (anti-conservative). A new per-exposurehas_own_short_term_ecaiflag distinguishes the directly-rated source (never a target) from spilled legs; the target predicate is now~own & (cqs null | spilled). Pinned bytests/acceptance/{crr,basel31}/test_p1_225_cofire_spillover_floor.py(LN-B 0.50 → 1.00, RWA 1.0M → 2.0M). - The
ccr_modelled_lgdnetting-set collapse is now deterministic and conservative (P1.215 follow-up). Both the SA-CCR pipeline adapter and the FCCM/SFT producer collapsed the own-estimate LGD carrier first-trade-wins over an unordered grouping, so a multi-trade netting set with heterogeneous modelled LGDs carried an order-nondeterministic value; both now take the maximum (highest LGD = largest capital requirement), pinned by an order-parametrized contract test. - Unrated corporate guarantors are no longer recognised as credit protection (P1.227). Art. 201(1)(g) admits corporates as eligible unfunded-protection providers only with an ECAI rating — or, under Art. 201(2), an internal rating where the protected exposure is itself on the IRB approach — but the engine recognised any corporate guarantor, pricing an unrated one at the 100% unrated-corporate default and granting substitution benefit (a CQS5 borrower's 150% covered leg wrongly dropped to 100%). The guarantor-approach assignment now gates the corporate limb: no ECAI rating and no (IRB-beneficiary) internal rating → the guarantee leg takes the established no-substitution path, the covered exposure reverts to its borrower basis, and a
CRM013warning names the ineligible provider. The Art. 201(2) internal-rating limb is beneficiary-scoped — the same internally-rated-only guarantor is eligible on an F-IRB exposure and ineligible on an SA one (both pinned); slotting beneficiaries are deliberately outside the internal limb (conservative, documented). Non-corporate providers keep their existing unconditional paths, and a fixture-estate sweep confirmed no existing scenario carried an unrated corporate guarantor, so the change is purely additive. A retail-class guarantor probe confirmed such providers already receive zero benefit via the guarantor risk-weight lookup's null-guard (documented as an accidental but real safety net). Ref: CRR/PS1-26 Art. 201(1)(g)/(2) w/ Art. 194(5); BCBS CRE22.73-76. - Short-term ECAI ratings mis-scoped onto ineligible obligor classes are now ignored and flagged (P1.264). Art. 140(1) confines short-term assessments to institution and corporate obligors, but the override applied to any scope-matched exposure regardless of class — a short-term rating wrongly attached to a sovereign exposure silently overwrote its CQS and short-term flag. Worse, once the Art. 140(2) contamination landed, such a mis-scope could force a genuinely unrated sovereign sibling to 150% (a pinned £500k RWA overstatement per £1M in the worked scenario). The override now gates on the obligor's entity type (pack-sourced institution/corporate set; SMEs carry corporate entity types), leaves the counterparty-level CQS untouched on rejection, and emits a
DQ009warning per mis-scoped exposure — including for obligors with null or unknown entity types, where a Polars Kleene-null would otherwise have swallowed the warning (guarded and pinned). The Art. 120(3)(c) spillover and Art. 140(2) contamination both run after the gate and inherit it. Correctly-scoped institution/corporate short-term ratings are untouched. Ref: CRR/PS1-26 Art. 140(1); BCBS CRE21.16. - A 150% short-term rating on any facility now contaminates the obligor's other unrated unsecured exposures, and a 50% short-term rating floors its unrated short-term exposures at 100% (P1.225). Art. 140(2) (CRR and PS1/26, CRE21.17-18) requires obligor-level spillover from adverse short-term issue assessments, but no such mechanism existed — an obligor with an ST-CQS4 (150%) facility kept its unrated long-term siblings at the 100% class default, and a 50% ST facility left unrated short-term claims at the 20% preferential. Two obligor-level flags are now built in the hierarchy stage (beside the Art. 120(3)(c) spillover, which remains independent — contamination forces the risk weight and never rewrites CQS) and consumed by a shared post-ladder override in the SA calculator under both regimes. Guarantee-substituted legs keep their guarantor risk weight ("unsecured" excludes them); collateralised-but-unguaranteed rows are contaminated (SA collateral acts on EAD, not RW); the Art. 127 defaulted handler runs last, so provision-based defaulted risk weights survive contamination (load-bearing ordering now commented). Worked deltas pinned in both regimes: unrated long-term corporate 100%→150%; unrated short-term institution 20%→100% (Basel 3.1 exercised via an SCRA-A base). Ref: CRR/PS1-26 Art. 140(2); BCBS CRE21.17-18.
- A-IRB firms' repo-style and derivative counterparty-credit-risk exposures can now route to A-IRB end-to-end (P1.215). The classifier's A-IRB gate requires a row-level own-modelled LGD, which the FCCM SFT and SA-CCR producers never emitted — so an A-IRB-permissioned repo silently fell back to F-IRB (supervisory LGD, M=0.5y) or SA, missing the Art. 162(2) effective-maturity carve-outs the engine already supports (e.g. the 5-business-day repo floor M=5/365). A new nullable
ccr_modelled_lgdinput on the SFT and derivative trade schemas — a dedicated carrier mirroringccr_effective_maturity, never the lendinglgdcolumn — flows through the CCR-only edges, widens the classifier gate, and feeds the IRB K formula on A-IRB CCR rows. The change is opt-in by data: with the input absent every row routes exactly as before (all lending goldens bit-stable), and it cannot bypass model permissions or the Basel 3.1 A-IRB restrictions for institutions and large corporates. The five strict-xfail acceptance anchors flipped green, with the flagship 5BD repo pinning K, maturity adjustment, LGD and M independently. Ref: CRR Art. 143/153 (A-IRB own-estimate LGD), Art. 162(2) (effective maturity). - The CRR Art. 164(4) portfolio-level A-IRB retail real-estate LGD-floor backstop now exists (P1.183). CRR (as amended by CRR2) requires the EAD-weighted-average own-estimate LGD across all A-IRB retail exposures secured by immovable property to stay at or above 10% (residential) / 15% (commercial), excluding exposures benefiting from central-government guarantees — a portfolio-level monitoring backstop, not a per-exposure input floor, and previously entirely unenforced under CRR (the Basel 3.1 per-exposure floors are a different mechanism and gate off under CRR). The aggregator now computes the EW-average per sub-class after aggregation and emits an
IRB007warning carrying the computed average, the floor, and the population size when breached; RWA and LGD are never adjusted (pinned bit-stable). The check is CRR-only via the cited pack Featurecrr_retail_re_portfolio_lgd_floor; the central-government-guarantee carve-out correctly excludes the guaranteed leg of a split exposure while keeping the retained leg in the average; a null-LGD row dilutes the average downward by design (conservative over-flagging, documented). Ref: CRR Art. 164(4)/(5); specdocs/specifications/crr/airb-calculation.md. - Unfunded credit protection whose provider can unilaterally cancel it is no longer recognised — the first Art. 213(1)(c)(i) eligibility gate (P1.10, re-scoped). Neither regime validated the Art. 213(1)(c)(i) protection-contract conditions: a guarantee (or credit derivative) the provider could unilaterally cancel — or, under Basel 3.1's new "or change" words, unilaterally change — still produced full substitution benefit. Two nullable Boolean inputs (
is_unilaterally_cancellable,is_unilaterally_changeable) now feed an eligibility gate in_prepare_guaranteesthat drops flagged protection before multi-level expansion, reverting the covered exposure to its borrower basis and emitting aCRM012warning per dropped guarantee. The change arm is gated by the cited pack Featureucp_unilateral_change_ineligible(Basel 3.1 on / CRR off — no regime branch in engine code). Nulls are permissive (null = no known defect), mirroring the Art. 237(2)(a) precedent, so every existing portfolio is number-neutral until a firm actually flags a defective contract. The Rule 4.11 transitional (P1.143) will later disapply only the change limb for pre-2027 contracts. Pinned by ten acceptance tests across both regimes (eligible 200k / ineligible 1M on the worked fixture; exact CRM012 counts as leak controls). Ref: CRR Art. 213(1)(c)(i) / PS1/26 Art. 213(1)(c)(i); CRE22.68. - The Art. 159 IRB expected-loss vs provisions comparison now nets at the default-status pool level, correcting a simultaneous CET1 over-deduction and T2 over-credit (P1.221).
compute_el_portfolio_summarysummed the per-rowmax(0, EL_i − pool_b_i)shortfall/excess columns, so one exposure's provision excess never offset a sibling exposure's EL shortfall inside the same pool — Art. 159(3) (CRE35.4) compares the pool totals. The summary now derives the non-defaulted/defaulted shortfall and excess from the aggregate pool sums (max(0, ΣEL − Σpool_b)); the per-row columns remain as audit trail, the Art. 159(3) two-branch selector and the Art. 62(d) 0.6% T2 cap are unchanged, and frames without expected-loss data keep the prior per-row-sum behaviour. On the CRR-G acceptance book the netted figures are 1,250 shortfall / 861,274.40 excess (previously 729,016.50 / 1,576,721.43 — both directions over-stated at once). Pinned by theTestP1221PoolAggregateNettingworked examples plus four inverted mixed-sign unit pins; the P1.127 pool-separation regression guard stays green, and the OF-ADJ unit helper was made internally consistent so its EL/provision inputs genuinely carry the shortfall it asserts. Ref: CRR Art. 159 / PS1/26 Art. 159 (CRE35.4); CRR Art. 36(1)(d), 62(d). - COREP and Pillar 3 were mis-scoping counterparty credit risk: SA-CCR derivatives were missing from C 07.00 (and, under Basel 3.1, dropped from it entirely), C 02.00 did not foot, OF 02.01 double-counted U-TREA, and CMS1 silently lost the whole CCR charge. Four templates move; C 09.01 inherits the C 07.00 population and moves with it. Found by an operator using the new report-cell drill-down: the C 07.00 lineage panel printed the scope note "SA-CCR derivatives are excluded — they report under C 34", they asked why, and the note turned out to be false. It was never a scope decision — it rationalised a defect. C 07.00 and C 34 are not alternatives: Annex II rows 0070/0080 say verbatim that "exposures that are subject to counterparty credit risk shall be reported in rows 0090 – 0130, and therefore shall not be reported in this row", C 34 analyses CCR by CCR approach while C 07.00 risk-weights those same exposures under the SA, and a derivative belongs in both (no roll-up sums the two — C 02.00, OF 02.01 and OV1 each read the ledger directly, so the wider population moves nothing twice). Four fixes, one workstream:
- COREP C 09.01 and C 02.00 now assign defaulted SA exposures to the "Exposures in default" row, as the Annex II instructions require (recorded decision 2026-07-12). The geographical breakdown's primary columns (original exposure, exposure value, RWEA) are defined "same as the CR SA template" — the Art. 112(j) ladder, under which a defaulted exposure moves to row 0100 exactly as C 07.00 assigns it. The engine had preserved the retired raw-class keying, leaving the defaulted exposure's amounts in its origination class row. Column 0020 "Defaulted exposures" is — per the instruction's own words — a memorandum reported "where the obligors would have been reported if those exposures were not assigned to the exposure classes 'exposures in default'", so it stays on the original class row: C 09.01 is now a two-basis template (applied-ladder primaries, original-class memo), and a class row whose only exposures defaulted keeps its memo while its primaries sit in row 0100. C 02.00's class rows make the same move (its SA breakdown must tie to C 07.00's applied-basis totals; values identical for IRB rows). On the reference portfolio the golden movers are exactly the defaulted corporate: C 09.01 row 0070 → row 0100 (1.0M exposure / 1.5M RWEA under CRR, memo unchanged), C 02.00 row 0130 → row 0160; totals unchanged. C 09.02 was confirmed correct as-is (the IRB template deliberately has no default row). A
LedgerShimCorepGenerator(mirroring the Pillar 3 shim) mirrors the sealed reporting projection onto synthetic unit frames. Ref: Reg (EU) 2021/451 Annex II C 09.1 (cols 0010/0020/0040, row 0100); PRA PS1/26 Annex II OF 09.01 (near-verbatim identical); CRR/PS1-26 Art. 112(1)(j). - Guarantees on slotting exposures finally produce RWA relief — closing the confirmed zero-benefit gap (recorded 2026-07-06; fixed via RWSM under both regimes by operator decision 2026-07-12). A guaranteed slotting exposure was physically split into its covered/retained legs at the CRM stage, but the slotting calculator had no guarantee-substitution step at all: a 10M project-finance Strong loan (70% RW) fully guaranteed by a CQS 1 sovereign kept RWA of 7.0M — with and without the guarantee — under both frameworks, and the covered leg's Art. 158(6) expected loss (40k) double-counted into the Art. 159 shortfall pool. The slotting branch now runs the exact same shared substitution step as the SA branch (Risk-Weight Substitution Method, PS1/26 Art. 235(1)): the covered leg takes the guarantor's SA risk weight when beneficial — with the identical beneficial-only gate (a weak guarantor can never increase RWA), multi-guarantor redistribution, and audit columns — and the covered leg's slotting EL is zeroed (Art. 235(1A); mirrors the existing IRB SA-guarantor precedent). The behaviour is gated by the new cited pack Feature
slotting_guarantee_substitution, enabled in both packs: the Basel 3.1 side is mandated by the PS1/26 Part 3 CRM decision tree; the CRR side is applied by analogy with the black-letter uncertainty recorded on the Feature's citation (Art. 235 is textually SA-scoped; COREP Annex II para 43 expects substitution flows on slotting rows) and reverts with a one-line flag flip. This also fixes a latent Basel 3.1 defect the fix's scouts uncovered: the output-floor SA-equivalent pass leaks SA-basis guarantee columns onto slotting rows, so the recently-addedguarantee_rwa_benefitwould have reported a wrong benefit (computed off the SA-default 100% base) on guaranteed slotting legs — the branch substitution now overwrites the leak with the true slotting-basis relief (10M × (0.70 − 0.00) = 7.0M in the hand-calc scenario), pinned by the Basel 3.1 acceptance twin. Hand-calc acceptance coverage: full cover (RWA 7.0M → 0; EL 40k → 0), 60% partial cover (RWA → 2.8M; EL → 16k on the retained leg), and the non-beneficial unrated-corporate guarantor (nothing changes). No existing fixture combines a guarantee with a slotting exposure, so every existing golden and test is untouched. Ref: PRA PS1/26 Art. 235(1)/(1A); CRR Art. 213-217; CRR Art. 158(6)/159. - The API/UI stat cards and the framework-comparison page now report on the same post-substitution basis as COREP and Pillar 3 — closing the operator-flagged UI/recon basis gap (Phase 7 Sn, decisions F5 + invariant 8). The SA/IRB/Slotting card totals (
SummaryStatistics.total_ead_sa/irb/slottingand the RWA twins, surfaced on the results-page stat cards and the REST comparison deltas) previously bucketed rows by the raw pre-guaranteeapproach_appliedagainst string-alias unions; they now read the sealedreporting_method(STD/FIRB+AIRB/SLOTTING), so a cross-approach guaranteed leg — an IRB obligor with an SA guarantor — reports on its guarantor's card, exactly as COREP C 07.00 does. The comparison estate (analysis/comparison.py) makes the same move: per-exposure deltas carry the post-substitutionreporting_class/reporting_approachunder name-stable aliases, the methodology label is read from the sealedreporting_methodinstead of being re-derived from the raw approach, and the capital-impact waterfall's IRB gate (scaling-factor and output-floor drivers) keys the post-substitution approach — which is the approach the engine actually scaled. This retargets the comparison page's by-class chart and table (the one remaining raw-class UI surface; the single-run results page moved at S4). Defaulted, SME-managed-as-retail and guaranteed rows change class/approach attribution in those views; portfolio totals are unchanged. No committed golden pinned the retargeted cells; the mock unit fixtures now populate thereporting_*columns the seal previously null-injected. Ref: CRR Art. 235 (substitution), Art. 112 (applied class). - The by-class / by-approach / by-method summaries now tie exactly to the portfolio RWA total — closing a CRR overstatement equal to the supporting-factor relief — and defaulted / SME-managed-as-retail exposures finally appear under their applied class (Phase 7 S4). The three persisted summaries (
summary_by_class,summary_by_approach,summary_by_class_method) were built from an internal "post-CRM detailed" view that re-split every guaranteed exposure a second time and reconstructedtotal_rwaasreporting_ead × reporting_rw— a basis that (a) excluded the Art. 501/501a supporting factors, so CRR summary totals exceeded the true portfoliorwa_finalby exactly the SME/infrastructure relief (measured +2.70M, ~1.9%, on the reference reporting portfolio — the UI's by-class chart disagreed with its own headline total); (b) priced IRB-guaranteed legs at the flat SAguarantor_rwinstead of the leg's actual Art. 161 parameter-substituted risk weight; (c) grouped non-guaranteed rows by raw origination class, so a defaulted SA exposure hid inside its origination class (nodefaultedbucket existed) while COREP C 07.00 already reported it underdefaulted; and (d) counted a phantom zero-EAD "unguaranteed portion" row per guaranteed leg. The summaries are now pure group-bys of the sealed per-leg reporting ledger (reporting_class/reporting_approach/reporting_method, Phase 7 S2) summing the sealed post-floorrwa_final— all six frames tie exactly to the portfolio total under both regimes (pinned), thedefaultedbucket appears, and counts reflect physical legs. The reconciliation's class/method allocation reads the same sealed columns (its values are unchanged — they alias the columns it already used), and all 14 multi-candidateour_columnsfallback ladders in the recon registry collapsed to single sealed names, killing the fictional rungs (sa_cqs,ccf_applied,irb_expected_loss, …) that unit fixtures had been pinning. The internalpre_crm_summary/post_crm_detailed/post_crm_summarybundle fields (dropped at the API boundary; audit-cache-only) were deleted with their generator module. Ref: CRR Art. 235/161 (substitution), Art. 112/127 (applied class), Art. 501/501a (supporting factors), PRA PS1/26 Art. 92(2A) (post-floor totals, P1.130 preserved). - A less-favourable short-term ECAI assessment now spills across all of an obligor's unrated short-term claims, not just the directly-rated exposure — closing an understatement on obligors with a poor short-term issue rating. CRR Art. 120(3)(c) (with the Art. 131 Table 7 short-term ladder) requires that when an obligor carries a short-term issue-specific ECAI assessment mapping to a less favourable (higher) risk weight than that obligor's general preferential short-term treatment, the preferential treatment is disapplied and all of that obligor's unrated short-term claims take the short-term assessment's CQS.
engine/stages/hierarchy/enrich.pyapplied the short-term override strictly per-exposure, so a sibling unrated short-term claim on the same obligor kept the 20% Table 4 preferential weight even though a directly-rated short-term exposure carried a CQS-3 (100%) Table 7 assessment. The new_apply_obligor_short_term_spilloverderives, per obligor, the worst short-term-assessment CQS within the short-term maturity window (≤3m, ≤6m for self-liquidating trade LCs) and — when the CQS-band "less favourable" test fires (general CQS 1-3 vs assessment ≥2; general CQS 4-5 vs assessment ≥3) — spills that CQS onto the obligor's unrated short-term claims only (long-term claims and other obligors are untouched). The gate is regime-independent (both short-term tables are identical across CRR and Basel 3.1 over this CQS range). Worked case: an unrated short-term claim moves from RW 0.20 to 1.00. Pinned bytests/acceptance/basel31/test_p1_223_art_120_3c_obligor_st_spillover.py(CRR + Basel 3.1 twins). Ref: CRR/PS1-26 Art. 120(3)(c); CRR Art. 131 (Table 7). - IRB double-default treatment is now reachable, so a guaranteed exposure whose double-default risk weight beats substitution finally receives it. Under CRR Art. 217 / 153(3) the double-default risk weight
rw_ddshould be compared, unfloored, against the substitution risk weight, applying whichever is lower. Butengine/irb/guarantee.py::_apply_double_defaultfirst flooredrw_ddatmax(rw_dd, guarantor_rw)and then gated on the strictrw_dd < guarantor_rw— a condition the floor makes permanently False, soguarantee_statuscould never take the valueDOUBLE_DEFAULTand every eligible exposure silently fell back to plain risk-weight substitution (an overstatement of RWA relative to the intended DD treatment). The spuriousmax(...)floor is removed; the gates now compare the unflooredrw_ddagainstguarantor_rwand emit it. CRR-only; the Basel 3.1 path (no DD) is untouched. Pinned bytests/unit/test_irb_double_default.py::TestDoubleDefaultReachable. Ref: CRR Art. 217 / 153(3). - Corporate / PSE / institution
bondcollateral is now routed to thecorp_bondhaircut class, so the Art. 197 CQS-eligibility gate fires instead of a flat 40% catch-all — closing an over-recognition of low-rated debt collateral. CRR/PS1-26 Art. 197(1)(c)/(d) (with the Art. 224 Table 1 supervisory haircuts) makes debt-security collateral eligible only through its CQS band, zeroing recognition below the eligibility floor. Butengine/crm/haircuts.py::_normalize_collateral_type_exprnormalised abondcollateral row toother_physicalunless the issuer was sovereign, so a corporate or PSE bond bypassed the Art. 197 CQS gate entirely — a CQS-5 corporate bond was recognised at 60% of market value (a flat 40% haircut) rather than being zeroed, and eligible bonds missed the Table 1 1-12%/20% band structure. A.when((collateral_type == "bond") & issuer_type ∈ {corporate, pse, institution})branch now routes these tocorp_bondahead of theother_physicalfallback; the sovereign passthrough is unchanged. This closes the merge-cluster P1.233 ≈ P1.236 in a single change. Pinned bytests/acceptance/crr/test_p1_233_art_197_corp_bond_collateral.py. Ref: CRR/PS1-26 Art. 197(1)(c)/(d); CRR Art. 224 (Table 1). - CRR undrawn "other commitments" (
risk_type=OC) now take the 50%/20% CCF split on original maturity, not residual — closing a final-year understatement on seasoned revolvers. CRR Annex I items 2(b)/3(b) set the Medium-Risk 50% CCF when an undrawn facility's original maturity exceeds one year and the Medium/Low-Risk 20% only when original maturity ≤ 1yr, but the OC override keyed onmaturity_date − reporting_date(residual), so every seasoned >1yr revolver silently dropped to 20% CCF in its final year — a systematic capital understatement.engine/ccf.py::_apply_oc_original_maturity_ccfnow derives original maturity from a new nullableoriginal_maturity_yearsinput field (elsematurity_date − value_date, else a conservative 50% Medium-Risk default), comparing against the existing 365-day pack threshold. Long-term/no-maturity behaviour and the Basel 3.1 flat-40% OC arm are unchanged. Pinned bytests/unit/test_ccf.py::TestOtherCommitCCF::test_sa_pipeline_oc_50_percent_crr_seasoned_original_maturity. Ref: CRR Art. 111(1) / Annex I items 2(b),3(b). - Guarantee coverage on off-balance-sheet commitments is now measured on the CCF=100% exposure basis, closing a ~47% RWA understatement on undrawn guaranteed commitments. The covered part Eg = min(GA, E) must measure E at 100% of an off-balance-sheet item's value, with the CCF re-applied to the covered/uncovered split afterwards (CRR Art. 235(1)/236(3); PS1/26 Art. 235(1)(a)/236(1)(a)) — but
engine/crm/guarantees.pypro-rated coverage against the post-CCFead_after_collateral, shrinking the coverage denominator and over-recognising cover on undrawn commitments. The guarantee split now derives the coverage ratio against_crm_basis(=ead_for_crm, the CCF=100% basis, falling back toead_after_collateralwhen absent so pure on-balance-sheet rows are unaffected), leaving total EAD invariant and only re-splitting RWA (worked case: total RWA 180,000 → 340,000). Pinned bytests/acceptance/crr/test_p1_218_guarantee_coverage_ccf_basis.py. Ref: CRR Art. 235(1)/236(3); PS1/26 Art. 235(1)(a)/236(1)(a). - Under Basel 3.1, institution-typed regional governments / local authorities / PSEs are now forced onto the Standardised Approach (quasi-sovereign class), closing an F-IRB understatement. PS1/26 Art. 147(3)(c)-(e) assigns regional governments, local authorities and public sector entities to the quasi-sovereign exposure class unconditionally — the "risk weight of 0%" qualifier in Art. 147(3) binds only point (g) (international organisations), not points (c)-(e) — and Art. 147A(1)(a) makes that class Standardised-Approach only. But
rgla_institution/pse_institutionwere excluded fromB31_SOVEREIGN_LIKE_ENTITY_TYPES, so an internally-rated one holding an institution F-IRB permission received a modelled risk weight below the mandatory SA weight (worked case ≈26% vs 50%). Both entity types are now in the SA-only set, sob31_sa_onlyfires for them under theapproach_restrictions_b31_applicableFeature (CRR path untouched). The premise was verified directly against the primary PS1/26 PDF (ps126app1.pdfp.89) after an initial drop recommendation based on the repo's ownbasel31skill/specs, which mis-state this rule — a follow-on docs correction is warranted. Pinned bytests/acceptance/basel31/test_p1_220_pse_institution_sa_only.py. Ref: PS1/26 Art. 147(3)(c)-(e) w/ Art. 147A(1)(a). - Guarantee maturity-mismatch scaling now uses the residual protection maturity, not the original contract term — closing an understatement on seasoned guarantees. In GA = G·(t−0.25)/(T−0.25),
tis the years remaining to the protection's maturity (CRR Art. 238(1) with 239(3); PS1/26 Art. 239(3)), butengine/crm/guarantees.py::_apply_maturity_mismatch_to_guaranteespreferred theoriginal_maturity_yearsinput over the residual derived frommaturity_date. Any dataset that populated the original term (needed for the separate Art. 237(2)(a) ≥1-year eligibility gate) therefore silently over-recognised a long-dated-but-nearly-matured guarantee, treating a seasoned guarantee as if no mismatch applied. The preference is now inverted — the residual frommaturity_datedrivest, withoriginal_maturity_yearsused only as a fallback when nomaturity_dateis supplied — while the Art. 237(2)(a) eligibility gate (which correctly reads the original* term) is untouched. Worked case: a 1yr-residual / 5yr-original guarantee on a 4yr exposure now scales GA to 200,000 (total RWA 840,000) instead of full recognition (RWA 200,000). Pinned bytests/acceptance/crr/test_p1_219_guarantee_maturity_mismatch_residual.py; the companiontest_p1_109/test_p1_200fixtures and expectations were re-pinned onto the residual basis. Ref: CRR Art. 238(1)/239(3); PS1/26 Art. 239(3). - The flat 20% domestic-currency risk weight for regional governments / local authorities / PSEs is now restricted to UK-sterling exposures, closing an understatement on EU-domestic-currency RGLAs. CRR / PS1/26 Art. 115(5) scopes the flat 20% to UK RGLAs denominated and funded in sterling, but
engine/sa/risk_weights.pyreused the compositeis_domestic_currency = is_uk_domestic | is_eu_domesticflag for the RGLA branch, so every EU-domestic-currency RGLA short-circuited to 20% ahead of the rating tables — an unrated Italian municipality (sovereign CQS3) received 20% instead of the Table 1A sovereign-derived 100%. The RGLA 20% branch on both regime override chains now gates on a dedicatedis_uk_domestic(GB & GBP) flag; the Art. 114(4)/(7) central-government 0% branch legitimately keeps the composite flag (EU-domestic-currency central-government exposures still qualify for 0%). Unrated EU RGLAs now route to Table 1A sovereign-derived and rated EU RGLAs to Table 1B own-rating; UK/GBP RGLAs still get 20%. Pinned bytests/acceptance/crr/test_p1_222_rgla_uk_gbp_flat_20.py(identical under CRR and Basel 3.1) and the invertedtests/unit/test_rgla_risk_weights.py::test_eu_domestic_currency_rgla. Ref: CRR Art. 115(5); PS1/26 Art. 115(5). - The A-IRB credit-conversion-factor fallback now uses the F-IRB supervisory CCF instead of the Standardised CCF, and suppresses modelled CCFs on out-of-scope items — closing an EAD understatement. Under CRR Art. 166(8)-(10) an A-IRB exposure with no own-estimate CCF takes the F-IRB supervisory CCF (75% for Art. 166(8)(d) credit lines / NIFs / RUFs), and own-estimate CCFs are admissible only across the Art. 166(8)(a)-(d) product scope (Art. 166(9)) — not for issued full-risk substitutes (FR/FRC), which take the Art. 166(10) supervisory value. But the CRR A-IRB branch of
engine/ccf.py::_compute_ccffilled a nullccf_modelledfrom the SA CCF (_sa_ccf_from_risk_type, 50%/20%) and honoured any modelled CCF regardless of risk type. It now fills from_firb_ccf_from_risk_typeand gates own-estimates behindin_166_8_scope = (is_obs_commitment | is_short_term_trade_lc) & risk_type ∉ {FR, FRC}, so a null-modelled MR commitment takes 75% (EAD 750,000 not 500,000 on a 1,000,000 nominal) and an issued FR item's spuriously low modelled CCF is suppressed to the supervisory 100%. The Basel 3.1 branch is unchanged. Pinned bytests/unit/test_ccf.py::TestArt16689CCFFallbackScope; three stale sibling tests that encoded the old SA-fallback behaviour were re-pinned. Ref: CRR Art. 166(8)/(9)/(10).
[0.3.13] - 2026-07-10¶
Added¶
- The reconciliation tab's asset-class allocation now splits by method, because each asset class is reported per method. COREP reports every asset class under the methodology it was calculated with (SA on C 07.00, IRB on C 08.0x), so a class-only allocation could hide a real difference: a class the two engines allocate to different approaches nets to zero at class level and reads as a clean match. Mapping the new
[components.approach]entry in the reconciliation TOML gives the legacy side a method to split on, and the engine produces a second, two-sided allocation frameclass_allocation_by_method(analysis/reconciliation.py::_class_allocation_by_method, sharing one_build_class_allocationbody with the existingclass_allocationso the two can never drift on normalisation, one-sided fills, column order or the non-finite guard). The method dimension is a pure partition of the class one — summing a class's methods reproduces its combined row exactly — so the existing tie-out is preserved while the per-method rows expose the disagreement as offsetting deltas. Unlike the our-side-onlysummary_by_class_method(which is at the collapsed borrower grain, for break attribution), this frame is genuinely two-sided. The UI (reconciliation.html) renders one grouped-bar section per method — Legacy vs Ours RWA by class, all sharing one bar scale via the sameui/views/method_split.pythe results and comparison tabs use — plus anEAD & RWA by risk class & methodtable ordered by the presentationMETHOD_ORDER; whenapproachis unmapped both are empty and the page falls back to today's combined chart + table, and the class×method break segment tells the analyst how to turn the split on. Exposed ascollect_class_allocation_by_method()onReconciliationResponseand as aClass Alloc by MethodExcel sheet /reconciliation_class_allocation_by_method.csv. Covered bytests/unit/engine/test_reconciliation.py::TestClassAllocationByMethod,tests/unit/ui/test_views_reconciliation.pyandtests/integration/test_ui_reconciliation.py. - A new per-exposure
approach_post_crmoutput column — the post-guarantee twin ofapproach_applied, mirroring whatexposure_class_post_crmdid for the class in 0.3.12. The by-method allocation above needs the post-substitution method, and the rawapproach_appliedis not it: a guaranteed exposure's__G_leg inherits the obligor's approach because the guarantee split (engine/crm/guarantees.py::_build_guarantor_sub_rows) copies the parent row and never rewritesapproach, so the leg is routed into the obligor's branch and each calculator setsapproach_applied = approach. Onlyguarantor_approach('sa'/'irb'/'') records the guarantor's treatment. Deriving the method fromapproach_appliedwould therefore file an AIRB corporate guaranteed by an SA sovereign under (central_government, AIRB) while a post-substitution legacy extract reports it under (central_government, STD) — a phantom break in exactly the substitution case the view exists to reconcile. The aggregator now derivesapproach_post_crm(engine/aggregator/aggregator.py::_add_post_crm_reporting_approach, CRR Art. 235): an SA-guaranteed leg reports asstandardised(Art. 235 risk-weight substitution treats the protected portion as a direct SA exposure to the guarantor), an IRB-guaranteed leg keeps the obligor's IRB approach (Art. 161 / CRE22.70-85 parameter substitution), and retained/unguaranteed rows keepapproach_applied. The rule itself is a single source (engine/aggregator/_crm_reporting.py::post_crm_approach_expr) now shared with the post-CRM detailed view, which previously re-derived it inline. The column is sealed onAGGREGATOR_EXIT_EDGEand so persists to the results parquet. Ref: CRR Art. 235, Art. 161. REC007— a new non-fatal reconciliation warning when the mapped legacy approach values do not resolve to a methodology. The gate for the by-method allocation is thatapproachis mapped, not that its values resolve: a legacy column coded"STA"/"1"/"Internal"passes straight throughmethod_label_exprto its own upper-cased label, which can never share a(class, method)key with ourSTD/FIRB/AIRBrows — the split renders, but every row is one-sided and the deltas are meaningless. One small diagnostic collect now names the offending values and tells the analyst to add a[components.approach]value_map, turning a silently-misleading split into an actionable warning. The message quotes the raw cells from the analyst's file rather than the labels they folded to, and a null approach is called out separately as an unpopulated column —method_label_exprmaps null to the synthetic labelOTHER, and telling someone to value-mapOTHER(a value they never supplied) would be useless. The diagnostic reads one projected, de-duplicated column off the legacy extract rather than the grouped allocation frame, so it never re-executes the by-method aggregation. The engine's methodology vocabulary is exported once asMETHOD_LABELS(engine/aggregator/_summaries.py) rather than re-listed.
Fixed¶
- A single exposure with a non-finite (NaN/inf) RWA no longer blanks the reconciliation tab's totals and their deltas — the tables now show the finite rows, and the bad rows stay surfaced as the existing REC006 warning. Every reconciliation total is a float
.sum(), and Polars sums a NaN summand to NaN (unlike anull, which it skips), so one bad exposure — most often an IRB maturity-adjustment blow-up at very low PD — poisoned that component's or class's whole total; the UI'sfill_nan(None)then rendered the poisoned total as an empty cell, and every column derived from it (delta,delta_pct,delta_rwa,delta_rwa_pct) went blank alongside it, hiding the dozens of exposures in that bucket that reconciled perfectly well. The headline tie-out stat cards showed the same NaN as a literalnanrather than a blank, becauseheadline_statscoerces via_f(which mapsNone → 0.0but passes NaN through untouched). The four total-producing builders inanalysis/reconciliation.py—_totals_tie_out,_class_allocation(via_sum_or_null),_summary_by_componentand_summary_by_group— now route each summand through a new_drop_nonfinitehelper that maps NaN/inf to0while leaving nulls untouched, sosumkeeps skipping a genuinely absent one-sided value (a null means "no row on that side"; a NaN means "the calculation broke") and a one-sided class still reports its full allocation delta rather than a null. The REC006ERROR_RECON_NON_FINITE_VALUEwarning is unaffected: its detector runs on the raw per-key frame before any summary builder, so the analyst still gets the banner naming the affected component and row count, and is still pointed upstream at the offending exposures — sanitising the sums makes the totals readable without ever hiding the condition. This mirrors the layering already established for the results page in 0.3.8 (display boundary excludes non-finite rows;AGG001/AGG002surface them), and the same deliberate line is held here: the reconciliation is an analysis surface, so its screen and CSV/Excel export agree, while the regulatory COREP / Pillar III outputs remain unsanitised. Covered by new tests intests/unit/engine/test_reconciliation.py(a NaNrwa_finalstill yields a finite tie-out total/delta/pct and still raises REC006; a finite per-componentsum_abs_delta; a finite per-classour_rwa/delta_rwawith EAD unaffected).
[0.3.12] - 2026-07-08¶
Added¶
- A new per-exposure
exposure_class_appliedoutput column reports the class that matches the applied risk weight — so SME-managed-as-retail and defaulted SA exposures are finally bucketed correctly in reconciliation and COREP. The routingexposure_classrecords origination and guarantee substitution but omitted two SA-only applied-treatment movements, so the class dimension (not the RWA — the numbers were always right) was mis-attributed for those rows: (1) an SME managed as part of a retail pool that took the 75% retail risk weight (CRR Art. 123 / PS1/26 Art. 123A) stayedcorporate_sme, even though Art. 122 corporate has no 75% band — a 75%-weighted SME logically entails the retail class; and (2) a defaulted SA exposure kept its origination class instead of routing to "Exposures in default" (CRR Art. 112(1)(j) / Art. 127), which wins over origination per PS1/26 Table A2 (high-risk, Art. 128, still outranks it). The aggregator now derivesexposure_class_applied(engine/aggregator/aggregator.py::_add_exposure_class_applied) with a predicate that mirrors the SA risk-weight branch exactly, so the reported class always tracks the applied RW. Only SA rows (approach_applied == "standardised") are re-mapped — IRB already reclassifies corporate→retail onexposure_classitself and reports default via a PD override rather than a class, slotting keepsspecialised_lending, and equity keepsequity— so every non-SA approach carriesexposure_classunchanged. The column is sealed onAGGREGATOR_EXIT_EDGE(contracts/edges.py) and therefore persists to the results parquet. The reconciliationexposure_classcomponent now prefers the post-guarantee class (see next entry) then this one (analysis/recon_registry.py, with the origination class surfaced as rationale), and COREP C 07.00 buckets on it (reporting/corep/generator.py) so defaulted SA rows land in the "Exposures in default" sheet (row 0100) and SME-managed-as-retail rows in the Retail sheet — both fall back to the originationexposure_classwhen the column is absent (frames produced before this release). Covered bytests/unit/test_exposure_class_applied.py(the applied-class predicate incl. priority ordering and per-approach preservation, the recon wiring, and the COREP C 07.00 re-bucketing). Ref: CRR Art. 112(1)(j), Art. 123, Art. 127; PS1/26 Art. 123A, Table A2. - A companion
exposure_class_post_crmcolumn lets the reconciliation tie out by asset class on a post-guarantee basis. Parallel-run reconciliation compares our per-class allocation against a legacy extract that is produced after guarantee substitution, so the guaranteed slice of an exposure must be reported under the guarantor's class, not the borrower's. A guaranteed exposure is physically split into a__G_guaranteed leg and a__REMretained leg (engine/crm/guarantees.py); both carry the obligor's originationexposure_class, with the guarantor's class held inpost_crm_exposure_class_guaranteed. The aggregator now derivesexposure_class_post_crm(engine/aggregator/aggregator.py::_add_post_crm_reporting_class, CRR Art. 235): the guaranteed leg takes the guarantor's class, everything else keeps the pre-substitutionexposure_class_applied. It is sealed onAGGREGATOR_EXIT_EDGE, and the reconciliation'sclass_allocationby-class money tie-out (analysis/reconciliation.py) buckets on it, so a guaranteed exposure's guaranteed portion is totalled under the guarantor's class and its retained portion under the obligor's — mirroring the post-substitution reporting. The two by-class reconciliation views are now cleanly separated: the allocation (money, ours vs legacy) is post-guarantee as above, while the break concentration (summary_by_exposure_class, and the per-key class label in the explorer) uses the obligor's applied class (exposure_class_applied, which is uniform across a guaranteed exposure's__G_/__REMlegs) so a break is attributed deterministically to its borrower class rather than an arbitrary leg — and the two are labelled distinctly in the thin UI (reconciliation.html). The per-exposure money tie-out is unchanged (EAD/RWA sum the same regardless of class), and a partially-guaranteed exposure is flaggedrecon_grain_heterogeneouson collapse so the split across classes is visible.exposure_class_appliedremains the pre-substitution class that COREP C 07.00 keys its sheet + substitution flows on. Covered bytests/unit/test_exposure_class_applied.py(guaranteed leg → guarantor class, retained/unguaranteed → applied class, unresolved-guarantor fallback) andtests/unit/engine/test_reconciliation.py(post-guaranteeclass_allocationtie-out). Ref: CRR Art. 235.
Fixed¶
- The COREP "of which: defaulted" filter now reads the pipeline's own
is_defaultedflag, so the SA memo populates._filter_defaulted(reporting/corep/generator.py) previously keyed ondefault_status/exposure_class == "defaulted"/pd_floored >= 1.0— none of which the live SA output carries — so the "of which: defaulted" rows were empty for standardised exposures even when the portfolio held defaults. It now checks the row-levelis_defaultedflag first (the same signal the C 08.05 IRB count path already reads, and which also captures row-level defaults on an otherwise-performing counterparty), falling back to the legacy columns for hand-rolled frames. Ref: CRR Art. 178. - CRR short-term-rated institution and corporate paper is now risk-weighted through Art. 131 Table 7, closing a systematic RWA understatement. When an exposure carried a dedicated issue-specific short-term ECAI assessment (
has_short_term_ecai=True), only the Basel 3.1 Table 4A/6A branches read the flag — the CRR risk-weight ladder had no Table 7 branch at all, so short-term-rated institution paper fell through to the Art. 120(2) Table 4 general short-term weight (CQS 3 = 20% instead of Table 7's 100%) and short-term-rated corporates fell through to the Art. 122 base join (CQS 4 = 100% instead of Table 7's 150%). Both are understatements of regulatory capital. The fix adds the cited pack tablecrr_short_term_ecai_risk_weights(CRR Art. 131 Table 7 = 20/50/100/150/150/150% for CQS 1-6;rulebook/packs/crr.py, read back via theengine/sa/crr_risk_weight_tables.pyshim — no regulatory scalar hard-coded in engine code) and twohas_short_term_ecai-gated CRR branches inengine/sa/risk_weights.py: a Table 7 branch prepended ahead of the Table 4 gate for rated institutions, and a new SME-excluded Table 7 branch for rated corporates. Long-term-rated exposures (is_short_term=False) still route through Table 4 (institutions) / Table 5-6 (corporates) unchanged, and Basel 3.1 behaviour is untouched. This closes the merge-cluster P1.216 / P1.224 / P1.226 from the 2026-07-07 Art. 111–241 compliance audit in a single change. Pinned bytests/acceptance/crr/test_p1_216_art_131_table_7_short_term_ecai.py(institution CQS 3 → RW 100% / RWA 1,000,000; corporate CQS 4 → RW 150% / RWA 1,500,000, both on a GBP 1,000,000 drawn loan). Ref: CRR Art. 131 (Table 7).
[0.3.11] - 2026-07-07¶
Added¶
- The reconciliation review can now hide zero-gross-exposure rows from the breakdown counts, so the real differences stop being buried in immaterial noise. Our engine emits legitimate our-only lines that collapse to a key with zero EAD — a fully-provisioned exposure, a zero-undrawn (but committed) facility row, a guarantee-remainder sub-row — and each is a correct
missing_rightthat adds nothing to any money total yet still counts in every breakdown (summary_by_bucket,summary_by_component, and the by-class / by-approach / by-class×method segment tables), inflating themissing_righttallies and hiding the material omissions among them. An opt-in "Hide zero-gross-exposure rows" toggle (off by default — an audit tool shows everything unless asked) now removes those rows from the overview breakdown counts, the per-key explorer, and the biggest-breaks worklist; the money tie-out and Σ|Δ| charts are untouched (a zero-gross row contributes nothing). Materiality is defined once in the engine (analysis/reconciliation.py): a new_materiality_columnstags every key withgross_exposure=max(|our EAD|, |legacy EAD|)(falling back to RWA when EAD is unmapped) and anis_immaterialflag (gross_exposure ≤ 1e-9), added to the widecomponent_reconciliationframe before any summary/breaks builder runs. The toggled breakdowns are re-derived by a new publicmaterial_summaries(recon)that reuses the very same private summary builders the bundle is assembled from — over the frame filtered to~is_immaterial— so the material view can never drift from the all-rows view (_active_components_from_framerebuilds the active-component list from the frame's columns, needing no engine-internal state on the response).breaks_detailnow carriesis_immaterialso the worklist can drop the rare zero-gross break. Plumbed throughapi/models.py(memoisedReconciliationResponse.collect_material_summaries()),ui/views/reconciliation.py(hide_immaterialonsegment_tables/summary_by_component_table/biggest_breaks/breaks_signoff_progress, a newForensicFilters.hide_immaterialapplied in_apply_forensic_filters),ui/app/main.py(thehide_immaterialquery param on the overview + explorer routes, threaded into the context, drill links and preserved across paging/sorting/sign-off), and thereconciliation.html/recon_explorer.html/_recon_table.htmltemplates (an overview callout with a hidden-row count, an explorer checkbox, and adrill_tableextra_qsargument that carries the toggle into segment drills). Covered by new tests intests/unit/engine/test_reconciliation.py(flagging +material_summariescount reduction, money-total invariance, RWA fallback, no-measure no-op),tests/unit/ui/test_views_reconciliation.py(segment / component / explorer / worklist honour the toggle),tests/integration/test_ui_reconciliation.py(the overview callout + hidden count and the explorer checkbox + preserved query, via the real routes), andtests/acceptance/reconciliation/test_reconcile_end_to_end.py(a zero-gross phantom dropped frommissing_leftthrough the fullCreditRiskCalc.reconcile()path).
[0.3.10] - 2026-07-07¶
Fixed¶
- Opening a single-loan forensic (clicking a row's key) from the reconciliation explorer no longer 500s with
polars ComputeError: parquet: File out of specification: The page header reported the wrong page size. Every reconciliation view — the overview, the segment tables, and the per-key explorer — renders from an eager DataFrame that is collected once (warmed on the worker thread, or memoised on first drill viaReconciliationResponse.collect_*), butloan_detail(ui/views/reconciliation.py) was the one drill path that instead re-executed the reconcile plan against disk on every click (scan_component_reconciliation().filter(_recon_key==key).collect()andscan_breaks_detail().filter().collect()). That freshscan_parquetre-read of the run'slast_results.parquet— with an equality filter that enables predicate/projection pushdown and its statistics/page-index-driven seeking read path — is materially more fragile than the full sequential collect the explorer does, so it alone tripped on a results parquet whose on-disk page offsets didn't line up (a streaming-sink_parquetwrite, or a torn/half-written file).loan_detailnow filters the already-collectedcollect_component_reconciliation()/collect_breaks_detail()snapshots in memory — exactly likeforensic_page,recon_fingerprintand every other view — so it never goes back to disk; as a bonus its numbers now match the explorer exactly (no second, independently-summed collect, which Polars group-by float sums make non-deterministic). The reconciliation CSV/Excel export buttons (api/export.py::_reconciliation_frames) had the same latent defect — they collected the raw lazy bundle frames directly, bypassing the memoised cache — and now read through the samecollect_*accessors. As defence-in-depth,ResultsCache(api/results_cache.py) now writes every parquet atomically (sibling temp file +os.replace) so a concurrentscan_parquetreader — the results-page pagination, a reconcile re-scan, an export — can never observe a half-writtenlast_results.parquetin the first place.
[0.3.9] - 2026-07-03¶
Added¶
- The reconciliation overview now carries a "By exposure class × method" segment — completing the methodology split across every UI surface that splits by exposure class. The Tier-2 "Segment" section already broke down where breaks concentrate by exposure class and by approach as two separate axes; it now also crosses them, showing how our breaks distribute across methodology (STD / FIRB / AIRB / SLOTTING / EQUITY) within each exposure class. A new engine summary
ReconciliationBundle.summary_by_class_methodgroups the per-key reconciliation frame by(our_exposure_class, method)— reusing the exact bucket-count/sum_abs_delta_rwaaggregations as the existing by-class summary (_summary_by_groupgeneralised to multiple group columns), so summingn_totalover methods within a class ties, cell-for-cell, tosummary_by_exposure_class(method is a pure partition). The methodology label comes from the same shared mapping the results/comparison tabs use (method_label_expr), applied toour_approach, so a rawadvanced_irbshows asAIRBconsistently. To make the segment drillable,methodis now a first-class column on the widecomponent_reconciliationframe and a first-class explorer filter (ForensicFilters.method,_FILTER_COLUMNS, the/reconciliation/{id}/rowsroute, and a Method dropdown in the explorer form), so a class×method cell links straight into the per-key explorer pre-narrowed on both dimensions (a newdrill_table2template macro builds the two-param link). This is deliberately an our-side view: approach is an our-engine attribute the legacy extract has no counterpart for, so the two-sided asset-class allocation tie-out above it stays un-split (a method-split allocation would need the legacy file to map an approach column) — the UI copy says so explicitly. Plumbed throughcontracts/bundles.py(bundle field +create_empty_reconciliation_bundle),api/models.py(collect_summary_by_class_method),ui/views/reconciliation.py(segment_tables,forensic_filter_options),ui/app/main.py, and thereconciliation.html/recon_explorer.html/_recon_table.htmltemplates. Covered by new tests intests/unit/ui/test_views_reconciliation.py(class-method partition, the by-class reconciliation invariant, method filter options, and single- and two-dimension explorer filtering),tests/unit/api/test_models.py(the cached accessor), andtests/integration/test_ui_reconciliation.py(the segment section + the explorer Method filter render). - The methodology split (STD / FIRB / AIRB / SLOTTING / EQUITY) now extends beyond the results-tab "RWA by exposure class" chart to every other place the UI splits by exposure class: the results-tab "EAD by exposure class" panel and the CRR-vs-Basel-3.1 comparison "by exposure class" view. Previously only the results RWA-by-class panel was split by method; the EAD panel next to it and the comparison's by-class chart showed a single undifferentiated bar per class, so a reviewer could not see, say, how much of the corporate EAD (or of the corporate CRR→B31 RWA delta) was standardised vs IRB. The section-building logic is now a single shared helper (
ui/views/method_split.py—single_series_sectionsfor one value column,grouped_series_sectionsfor a two-series CRR-vs-B31 chart), so the three tabs render one consistently-ordered chart section per methodology present and cannot drift; the methodology label stays sourced from the one engine mapping (engine/aggregator/_summaries.py, now exposed as the publicmethod_label_expr) rather than re-derived per surface. The results EAD split reuses the existingsummary_by_class_methodframe (which already carriestotal_ead), so it needs no engine/cache change. The comparison split is derived from the sameexposure_deltasframe that backs the by-class chart —exposure_deltasnow carries amethodcolumn (analysis/comparison.py), andui/views/comparison.py::summary_by_class_methodgroups it by(exposure_class, method)— so the per-method bars sum cell-for-cell to the by-class totals shown alongside them (verified against the multi-methodology reporting portfolio: max reconciliation diff0.0on both CRR and B31, and equity surfaces with real RWA, nothing falls into anOTHERbucket). Chart sections in a set now also share one bar scale (an optionalmax_valueonui/views/charts.py'shorizontal_bar_svg/grouped_bar_svg), so a small method reads as genuinely small next to a large one instead of each section rescaling to its own max. Covered by new unit tests intests/unit/ui/views/test_method_split.py(ordering, empty tolerance, RWA+EAD from one frame, shared-scale shrink),tests/unit/ui/views/test_comparison.py(class-method partition + the by-class reconciliation invariant),tests/unit/ui/views/test_charts.py(the shared-scale param), and extendedtests/integration/test_ui_app.pyassertions (both results panels split by method; the comparison class×method panel renders). - Parallel-run reconciliation now links our results to legacy/old data on the original, pre-concatenation exposure reference — so guarantor-split and facility-undrawn rows stop breaking the join. Our per-exposure output deliberately mutates
exposure_referenceat several sites — guarantee splits append__G_<guarantor>/__REM…, real-estate splits append_rre/_cre/_res…, facility undrawn synthesises<facility>_UNDRAWN[_<sub>|_RESIDUAL], and synthetic derivative/SFT rows are prefixedccr__/ft__/dfc__— but a legacy calculator's extract keys each exposure on its original reference, so those suffixed/synthetic references matched nothing and the reconciliation silently dropped them (undrawn especially: it had no recovery path at all, and the built-in collapse only ever un-split guarantee and RE rows). A new always-present, always-non-nullsource_exposure_referencecolumn now carries the pre-concatenation base on every result row: loans/contingents set it to their own reference (engine/stages/hierarchy/unify.py) and guarantee / RE-split sub-rows inherit it unchanged (they only mutateexposure_reference), so it recovers the original loan reference on collapse; facility-undrawn rows — including every MOF waterfall / residual sub-row — set it to the facility reference (engine/stages/hierarchy/facility_undrawn.py), the grain a legacy file keys undrawn commitments on; the equity path (engine/aggregator/_equity_prep.py) and the synthetic CCR/SFT builders (engine/ccr/pipeline_adapter.py,engine/sft/fccm.py,engine/stages/ccr.py) populate it too. It is declared on the sealed edge contracts besidesource_facility_reference(contracts/edges.py—_hierarchy_resolved_columnsand_calc_output_common_columns), so it survives the full pipeline to theresultsframe and the exported parquet/CSV. The reconciliation collapse now coalesces it first (data/schemas.py::RECON_PARENT_KEY_COLUMNS), so on the defaultexposure_referencekey guarantee/RE/undrawn sub-rows are automatically rewritten to their base reference before the join — guarantee and RE recovery is byte-for-byte unchanged (their base equals the parent-link column already used), undrawn now links to the legacy facility line, andparent_exposure_reference/split_parent_idare retained as defensive fallbacks for result parquets written before this column existed. Syntheticccr__/ft__/dfc__rows deliberately keep their namespace in the base (a bare netting-set / trade / contribution id could otherwise collide with an unrelated loan reference and silently sum EAD on a base-grain key), so they correctly remain our-only lines unless the legacy file reports those aggregates too. The base is also usable as an explicit reconciliation key (our_keys = ["source_exposure_reference"], documented in the UI mapping editor). Covered by new unit tests intests/unit/engine/test_collapse.py(undrawn → facility, MOF multi-sub → one line, guarantee recovery unchanged, null fall-through, namespaced-CCR no-merge) and an integration contract intests/integration/test_source_exposure_reference.py(non-null on 100% of a real CRR run's rows; undrawn strips_UNDRAWN; guarantee splits strip their suffix). Note: this changes the default-key reconciliation grain for portfolios containing facility-undrawn rows (they now collapse to the facility base line instead of surfacing as unmatched<fac>_UNDRAWNour-only lines); a loan whose reference literally equals a facility reference would collapse with that facility's undrawn line (the heterogeneity flag surfaces mixed class/approach in such a group).
[0.3.8] - 2026-06-30¶
Added¶
- The results page now splits "RWA by exposure class" by methodology — STD / FIRB / AIRB (advanced IRB, retail folded in), plus slotting / equity where present — so a reviewer can see how much RWA each methodology contributes within each exposure class. Previously the panel showed a single bar chart of total RWA per class with no way to tell, say, how much of the corporate RWA was standardised vs IRB. A new aggregator summary frame
summary_by_class_method(engine/aggregator/_summaries.py::generate_summary_by_class_method) groups the same post-CRM reporting rows by(exposure_class, method), reusing the identical reporting columns and aggregation expressions as the by-class summary — so summingtotal_rwaover methods within a class reconciles exactly withsummary_by_class(guarantee splits and the output-floor add-on already folded in). The method label is derived from the calculation approach (standardised → STD,foundation_irb → FIRB,advanced_irb → AIRBwith retail A-IRB included,slotting → SLOTTING,equity → EQUITY); there is no separate "retail IRB" approach, so retail A-IRB folds into AIRB. The frame is plumbed through the bundle (AggregatedResultBundle.summary_by_class_method), the parquet cache (ResultsCache.sink_results/scan_summary_by_class_method), and the response (CalculationResponse.scan_summary_by_class_method); the results page renders one sub-titled "RWA by exposure class" chart per methodology present (ui/app/main.py::_class_method_sections→templates/results.html), falling back to the single combined chart when the split is unavailable. Covered by new tests intests/unit/test_aggregator.py(shape, method labels, by-class reconciliation, retail-A-IRB→AIRB),tests/unit/api/test_results_cache.py(round-trip),tests/unit/api/test_formatters.py(path populated), andtests/integration/test_ui_app.py(the STD subsection renders).
Fixed¶
- Parallel-run reconciliation can finally compare PD, LGD and guarantee on a real IRB run — previously every such mapping was silently skipped with a
REC001 "no column for component …"warning. The reconciliation registry (analysis/recon_registry.py) looked up each component's value on our results frame (scan_results()) by a list of candidate column names, but the candidates forpd,lgdandguaranteewere authored against the documentary, never-enforcedCALCULATION_OUTPUT_SCHEMA(data/schemas.py) rather than the engine's actual output: it triedirb_pd_floored/irb_pdandirb_lgd_floored/irb_lgd(andguarantee_benefit), none of which the engine ever emits. The IRB calculator writes the un-prefixed namespd_floored/pd,lgd_floored/lgd_input/lgd(engine/irb/{formulas,transforms}.py), and the only persisted, additive guarantee figure on the combined output isguaranteed_portion(theguarantee_benefit/guarantee_benefit_rwRWA-reduction columns never reach the per-exposure output) — so_first_present(...)returnedNoneand the component was dropped, leaving the analyst's PD/LGD/guarantee comparison blank even though the engine had computed them. (maturitywas unaffected because the engine genuinely renamesmaturity → irb_maturity_m, which is a registry candidate.) The fix repoints the three components to the real output names:pd → (pd_floored, pd),lgd → (lgd_floored, lgd_input, lgd)(mirroring the reporting layer's_pick(cols, "lgd_floored", "lgd_input")), andguarantee → (guaranteed_portion,)reconciling the additive guaranteed EAD portion (its explain/input drivers and theeadcomponent's de-dup driver list were corrected to real columns to match, and the UI's default-mapping hint now points at a guaranteed-amount legacy column). The fictionalirb_-prefixed names are also annotated as non-enforced inCALCULATION_OUTPUT_SCHEMA/IRB_RESULT_SCHEMAanddocs/data-model/output-schemas.md, and the staleanalysis/reconciliation.pymodule docstring now points at the sealedAGGREGATOR_EXIT_EDGEas the real output contract. The root cause was a test gap — no test ranreconcile()against a real IRB portfolio mapping PD/LGD (the unit suite fed SA-shaped frames and even codified the skip as expected behaviour) — so the fix addstests/integration/test_reconciliation_output_contract.py, which runs the real IRB pipeline once and asserts every key component'sour_columnsresolves against the live output schema (catching any future engine column rename), plus a positive resolution test intests/unit/engine/test_reconciliation.py. UI-view tests that built ours frames keyed onguarantee_benefitwere updated toguaranteed_portion. - A single IRB exposure with a non-finite (NaN/inf) RWA no longer blanks the entire results page — the totals, per-approach card and charts now render the real numbers for the unaffected rows, and the bad rows are surfaced as an explicit error. A genuine
NaNin a per-rowrwa_final/ead_final/risk_weight(most often from a NaN PD/LGD reaching the IRB formula, or a zero-EAD guaranteed row dividing0/0) propagated straight through Polars' float.sum()— which, unlike anull, does not skip a NaN — turningtotal_rwa,total_rwa_irband the average risk weight intoDecimal('NaN')(rendered literally as "NaN", andDecimal('NaN') > 0even raised, masquerading as a whole-page failure) and collapsing both SVG charts to 1px slivers (a NaN poisoned the bar-scalemax_value). The fix is layered: (1) the IRB PD/LGD floors nowfill_nan(None)before flooring, so a NaN PD/LGD is treated like a null and raised to its regulatory floor — conservative and finite (engine/irb/{formulas,transforms}.py;max_horizontal/clip/fill_nulldo not scrub NaN, onlyfill_nandoes); (2) the IRB guarantee-blend now guards the EAD divisor so a zero-EAD guaranteed row yields a finite0instead of0/0 → NaN/x/0 → inf(engine/irb/guarantee.py); (3) the output aggregator detects any remaining non-finite output and records a non-criticalAGG001CalculationErrornaming a sample of the offending exposures, so the gap is a visible coded issue rather than a silent blank (engine/aggregator/aggregator.py); and (4) the display boundary excludes non-finite rows from the card sums (api/formatters.py) and the chart item builders / bar-scale (ui/app/main.py,ui/views/charts.py), so the screen stays readable while the error explains the exclusion. The summary parquet / COREP / Pillar III exports are deliberately not sanitised — silently zeroing a regulatory output there would be worse than surfacing it. The detector additionally (5) raises a non-blockingAGG002warning when a raw IRB input (pd/lgd) was non-finite and got raised to the regulatory floor — so the conservative scrub in (1) is visible rather than absorbed silently — and (6) theAGG001scan also covers the post-CRMreporting_rw/reporting_eadcolumns the by-class/by-approach charts aggregate (de-duplicated against the output scan so the same exposure is never reported twice). Separately, the results cache now clears a stale summary parquet when a later run writes none, so an error run can no longer resurrect a previous run's by-class / by-approach / by-method summary. Covered by new tests intests/unit/test_aggregator.py(AGG001 detection, AGG002 input warning, reporting-column de-dup),tests/unit/api/test_formatters.py(NaN excluded from totals, noInvalidOperation),tests/unit/api/test_results_cache.py(stale-summary clearing),tests/unit/irb/test_irb_formulas.py(NaN PD/LGD → floor, finite RWA, on both the formula and production paths),tests/unit/irb/test_irb_parameter_substitution.py(zero-EAD guarantee stays finite), andtests/unit/ui/views/test_charts.py(non-finite values dropped/zeroed, scale not poisoned).
[0.3.7] - 2026-06-28¶
Added¶
- The COREP and Pillar III Excel exports now carry a readable column-name banner above the regulatory ref codes, so a reader no longer has to decode bare
0010/a/bheaders. Every template column is defined with both a regulatory ref and a human-readable name (COREPColumn.name/P3Column.nameinreporting/{corep,pillar3}/templates.py), but only the ref reached the workbook — each sheet's header row wasrow_ref, row_name, 0010, 0020, …(COREP) or…, a, b, c, …(Pillar III), even though the rows already carried a readablerow_name. Each exported sheet now writes a two-row header band: row 1 (top) is the readable column-name banner ("Row code"/"Row name" over the two structural columns, then each column's name, e.g. Original exposure pre conversion factors over0010), row 2 is the unchanged regulatory ref codes, and the data follows below; the top two rows are frozen so both stay visible while scrolling the (often very wide) templates. The banner is written as Excel cells only — the underlying template DataFrame schema is untouched (still keyed by ref), so the ndjson golden comparisons and any code-stable downstream consumer are unaffected; a column with no static name (none on the Excel path today) falls back to showing its ref. Implemented as one shared writerreporting/kernel/excel.py::write_template_sheet(it also absorbs the former per-generator_finite_onlynon-finite→blank guard and sheet-name sanitisation, removing the duplicated copies), threaded the per-template{ref → name}map (column_name_map) from each generator'sexport_to_excel. Covered by new banner-layout regression tests intests/unit/test_corep.pyandtests/unit/test_pillar3.py(row 0 = readable names, row 1 = refs), with the existing non-finite-cell tests updated for the one-row data offset. - Pillar III disclosure templates can now be exported from the UI and REST API, alongside COREP. The engine already produced the full Pillar III quantitative credit-risk disclosure suite — OV1; SA CR4/CR5; IRB CR6/CR6-A/CR7/CR7-A/CR8/CR9/CR9.1; slotting CR10; output-floor CMS1/CMS2; counterparty CCR1/CCR2/CCR3/CCR8 (UK-prefixed sheets under CRR, UKB-prefixed under Basel 3.1) — but it was reachable only from Python via
ResultExporter().export_to_pillar3(...): there was noCalculationResponse.to_pillar3(), no REST endpoint, and no UI button, so a user could download COREP but not Pillar III. Pillar III is now wired through every export surface that COREP already had: aCalculationResponse.to_pillar3(path)convenience method (api/models.py); the RESTGET /api/export/{fmt}?run_id=…endpoint acceptspillar3and streams the workbook (api/rest.py); the results page shows a Pillar III download button next to COREP, andpillar3is an option in the calculator's at-run-time and the results page's Save to folder format pickers (it writesrwa_pillar3.xlsxinto the run-stamped subfolder viaoutput_writer). Like Excel/COREP it needsxlsxwriterand is greyed out / reported per-format when that is missing, and the user-suppliedrun_idnever reaches a filesystem path (fresh temp dir + fixed literal filename). Covered byTestExportToPillar3(tests/unit/api/test_export.py), the REST export parametrisation,tests/unit/ui/test_output_writer.py, and an extended results-page render assertion intests/integration/test_ui_app.py. - The CRR vs Basel 3.1 comparison page can now export its results, like the calculator and reconciliation pages. Previously the comparison ran both frameworks, rendered the executive summary / waterfall / by-class table, and then threw the result away — the
ComparisonBundle+CapitalImpactBundlewere locals in_compute_comparison, never cached, so there was no way to take the numbers off the screen (the page had no download UI and nothing was registered to export). The comparison is now cached under a freshcomparison_id(a new_COMPARISONSregistry inapi/rest.py, peer to_RUNS/_RECON_RUNS) the moment it renders, and the page shows CSV / Parquet / Excel download buttons wired to a newGET /api/comparison/export/{fmt}?comparison_id=…endpoint. The export carries exactly what the comparison is for — the executive-summary headline, the by-class and by-approach delta summaries, the capital-impact driver waterfall, and the per-exposure deltas / driver attribution — as one file per dataset (CSV and Parquet stream as a zip; Excel as a single multi-sheet workbook with friendly sheet titles). This mirrors the reconciliation export precedent rather than the calculator's parquet-path-backed/api/export, because the comparison holds in-memory lazy bundle frames, not a sunk-to-parquetCalculationResponse; a newComparisonExportResponse(api/models.py) collects each frame once into an eager DataFrame at registration (not the lazy two-pipeline bundle graph), so a registry entry stays light. The Excel button greys out whenxlsxwriteris absent (matching the results page), and as with the other export endpoints the user-supplied id never reaches a filesystem path (fresh temp dir + fixed literal filenames). The comparison page renders inline (no background job), so the id and download URLs ride along in the same POST response — no redirect. Covered bytests/unit/api/test_export.py::TestComparisonExport(one file per frame, Parquet round-trip, Excel sheet titles, missing-xlsxwriter raise),tests/integration/test_rest_api.py(CSV/Parquet/Excel stream + unknown-id404), and an extendedtests/integration/test_ui_app.pycomparison render assertion.
Changed¶
- The calculator progress stepper now shows a "Create exports" step when a run writes export files, so the end-of-run wait is labelled instead of looking like a hang. When an output folder + formats are chosen, the calculator writes the selected exports (Parquet/CSV/Excel/COREP/Pillar III) after the pipeline finishes — a step that has no registry stage, so the stepper's spinner previously parked on the last pipeline stage (or appeared to "circle" with everything ticked) while the workbook(s) wrote, with no indication of what was happening. The stepper now appends a synthetic Create exports step (mirroring the reconciliation stepper's "Reconcile & summarise" tail): the spinner parks on it honestly while the files write, and it ticks off when they are done. The step is shown only when the run actually writes exports (
Job.writes_exports); a plain run with no output folder ends at the aggregator as before. The reportedtotal_stages(the registry pipeline count) is unchanged — the export step is a UI-only tail. Covered by extendedtests/integration/test_ui_app.pyassertions (the step renders and ticks for an export-writing run, and is absent otherwise).
Fixed¶
- The COREP export no longer crashes when a template cell computes a non-finite value. Like the Pillar III export, a COREP template ratio can be
NaNor±Infon real data — e.g. a ratio over a zero denominator in an empty exposure-class or geography segment — andxlsxwriterrejects those inwrite_number()(NAN/INF not supported … without 'nan_inf_to_errors'), aborting the whole workbook. The three COREP workbook writers (reporting/corep/generator.py) now replace non-finite floats with null at the write boundary, so an undefined cell is shown blank rather than#NUM!or a crash; existing nulls and non-float columns are untouched. Covered by a newTestExcelExportregression test. - The CRR vs Basel 3.1 comparison charts no longer clip long category labels off the left edge of the card. The server-rendered SVG chart builders (
ui/views/charts.py) drew every category label right-aligned inside a fixed ~142-unit left gutter (text-anchor="end"atx = _LABEL_W - 8), so any label wider than ~20 monospace characters overran the viewBox left edge and was clipped — most visibly the capital-impact waterfall driver names ("Methodology & parameter changes","Supporting factor removal (SME/infrastructure)"), which showed only their tail (e.g. just "& parameter changes"). The three builders (horizontal_bar_svg,grouped_bar_svg,waterfall_svg) now detect, per chart, when any label is too wide for the gutter and switch that chart to a label-above-bar layout: the label gets the full chart width on its own line, left-aligned, with the bar beneath it, so the full text always renders. Charts whose labels all fit (the results page's exposure-class/approach charts, the reconciliation charts) are unchanged — the compact gutter layout is byte-identical, so nothing regresses. Pure server-side SVG (no CSS or template change; the existing--oah-*theme classes are reused). Covered by newtests/unit/ui/views/test_charts.py(both layouts pinned: short labels stay compact, a long label stacks and renders in full, taller viewBox, value text in both, empty placeholder). - The Pillar III export no longer crashes when a disclosure cell computes a non-finite value. On real portfolios a template ratio can be
NaNor±Inf— e.g. an average PD or a risk-weight ratio over a zero denominator in an empty segment — andxlsxwriterrejects those inwrite_number()(NAN/INF not supported … without 'nan_inf_to_errors'), which aborted the whole workbook. The Pillar III workbook writer (reporting/pillar3/generator.py) now replaces non-finite floats with null at the write boundary, so an undefined disclosure value is shown as a blank cell rather than#NUM!or a crash; existing nulls and non-float columns are untouched. Covered by a newTestExcelExportregression test.
[0.3.6] - 2026-06-27¶
Added¶
- The reconciliation analyst can now sign off each difference — Accept (acceptable) or Reject (genuine error) with a free-text reason — and the worklist filters those decisions out, so the Open list burns down to just what still needs investigating. Previously the reconciliation surface was entirely read-only: an analyst could drill tie-out → segments → break worklist → single-loan forensic, but had nowhere to record that they had checked a difference or why it is (un)acceptable, so on a several-hundred-break portfolio there was no way to tell reviewed from un-reviewed. A new per-user, per-dataset sign-off store (
ui/app/recon_signoff.py, mirroringrecon_state.py) persists one decision per exposure row, keyed by the deterministic_recon_key, under a stable workspace id (a hash of the resolved data path + mapping join-keys) — so decisions survive an app restart and a re-run of the same dataset (re-run after a source fix and your prior sign-offs carry over; differences you resolved simply drop off). Writes are atomic (temp file +os.replace) and every IO path is error-swallowing, so a sign-off click can never 500 and a corrupt store loads as empty. The decision control lives on the single-loan forensic page (recon_loan.html): a reason<textarea>plus Accept / Reject / Reopen submit buttons posting to a newPOST /reconciliation/{recon_id}/signoffroute (guarded by the existingrequire_same_originCSRF belt,303-redirecting back so the actioned row leaves the Open list); a reason is required to reject (the page re-renders with a400+ error callout otherwise) and optional to accept. The per-key explorer (recon_explorer.html) now defaults to a newstatusfilter (Open/Accepted/Rejected/All, Open first), renders each row's status as a colour-coded badge with its reason, and offers a one-click inline Accept per row for fast triage; the explorer and overview both show an "X of Y breaks reviewed — Z open" burndown, and the overview worklist (biggest_breaks) and ranked break list now exclude already-reviewed keys. The inline Accept is a progressive enhancement (static/recon-signoff.js): on the Open worklist it POSTs in the background (X-Requested-With: fetch→ a small JSON burndown payload instead of the303) and drops just that row, so the analyst keeps their scroll position and can run down the list ticking items off without the page jumping to the top; with JS disabled (or on any non-Open view) it falls back to the normal full-reload form post. - Re-running the same dataset now re-flags any sign-off whose difference has moved since it was signed off, so a regression cannot be waved through under a stale approval; and analysts can clear all sign-offs at once. Each decision stores a fingerprint of the difference at sign-off time (
Decision.fingerprint): the row bucket plus, per still-breaking component, aname:bucket:our~legacytoken where numbers are banded to 4 significant figures (_value_token/_delta_band, in canonical scientific notation) and categoricals are normalised. On a re-run the fingerprint is recomputed (current_fingerprints, off the cached wide frame) and compared: if it differs while the row is still a material difference (not exact/within-tolerance), the decision is stale — the row returns to the Open worklist with achangedbadge / overview "changed since sign-off" count and, on the loan forensic, a re-review warning showing the prior disposition; re-signing re-stamps the fingerprint. Because the token bands both sides' values (not justabs_delta, which is null for categoricals and one-sided breaks), a legacy reclassification (e.g. retail → sovereign) on an accepted class break, or a break that moves to a different component, is now caught — not just a same-component magnitude change. The fingerprint is float-noise-robust (4 sig figs absorbs Polars' non-deterministic group-by float sums; canonical notation avoids a false break at decade boundaries), so an identical re-run never false-flags. A new Clear all sign-offs control (overview + explorer, confirm-guarded) wipes every decision for the dataset viaPOST /reconciliation/{id}/signoff/clear-all(same-origin guarded); the unguardedGET /reconciliation/resetside-effect was also brought under the same-origin guard. Covered by added unit tests (moved-categorical and moved-to-a-different-component staleness, fixed-row-shows-matched, within-tolerance-resolution, decade-boundary band, empty/failed-response safety) and integration tests (stale-on-rerun, re-accept clears, overview changed state, clear-all from overview/explorer). The change was hardened against a 5-lens adversarial review. Implemented byannotate_signoff/breaks_signoff_progressand astatusdimension onForensicFilters/_FILTER_COLUMNSinui/views/reconciliation.py; aReconWorkspaceregistry inapi/rest.pybinds each run'srecon_idto its sign-off workspace (the UI recon worker computes it from the parsed mapping). The signoff status derives asaccepted/rejected(a stored decision),matched(an exact-match row — never a difference, never in the Open list) oropen(an un-actioned difference). No engine, REST-export, or loader change. Covered bytests/unit/ui/test_recon_signoff.py(store round-trip / workspace isolation /workspace_iddeterminism / atomic write / resilience),tests/unit/ui/test_views_reconciliation_signoff.py(annotation buckets, default-Open filtering, worklist burndown, progress counts) andtests/integration/test_ui_reconciliation.py(accept clears Open and returns under Accepted, reject-needs-reason400, reopen, same-origin guard, unknown-recon404, and persistence across a re-run of the same dataset). - The reconciliation single-loan forensic now reads as an ordered RWA-driver chain with legacy beside ours at each step, and collateral / guarantee / CQS are first-class comparable components — so an analyst can see why a loan's RWA differs, not just that it differs. Previously the loan view (
ui/views/reconciliation.py::loan_detail→templates/recon_loan.html) rendered a flatBy componentpanel grid plus an our-side-only, unorderedInput & explain driverskey=value dump.loan_detailnow also returns an orderedstepschain (exposure class → approach → CQS → PD → LGD → maturity → CCF → collateral → guarantee → EAD → risk weight → RWA, module-level_CHAIN_ORDER); each step showslegacy | ours | absΔ | relΔ | statusfor the mapped component and nests that component's explain/input columns beneath it as our-side driver rows, each tagged "legacy not provided" so a one-sided field is never mistaken for a zero (new_driver_chainhelper; the template renders the chain, andrecon_loan.htmlkeeps the pinnedLoan forensic/By componentheadings). Three newRECONCILABLE_COMPONENTS(recon_registry.py) make the previously buried CRM drivers genuinely comparable:collateral(collateral_adjusted_value, additive) andguarantee(guarantee_benefit, additive) sum across split sub-rows to the key grain like EAD/RWA, andcqs(sa_cqs/external_cqs, exact-int) — so once a legacy column is mapped they surface everywhere (the single-loan chain, the per-key explorer grid, the totals tie-out, the asset-class allocation, and the CSV/Excel export) with no loader, export, or REST change (the loader maps any registered component by name; the export dumps the whole frame). PD/LGD/CCF/maturity/risk-weight/exposure-class were already comparable — they just need a legacy column mapped; the page'sDEFAULT_MAPPING_TOMLnow ships commented[components.pd|lgd|cqs|collateral|guarantee]examples. Numeric component deltas are now cast toFloat64so an integer-valued component (CQS) no longer breaks the per-component summary concat. Behaviour note: mappingcollateral/guaranteeadds them to the Tier-1 totals tie-out (intended — total CRM benefit ties out). Rating grades (vs PD/CQS) are deferred — they would need a newCALCULATION_OUTPUT_SCHEMAcolumn. Covered by new tests intests/unit/ui/test_views_reconciliation.py(chain order, driver grouping/our-side-only, the three new components' bucketing) withtests/integration/test_ui_reconciliation.pygreen. Phase 5 ofdocs/plans/reconciliation-ux-redesign.md. - The calculator UI can now write results straight to a folder you choose, instead of leaving them only in the in-memory registry reachable through a hand-typed REST URL. Three complementary paths land together: (1) an optional Output folder + Output format(s) (Parquet/CSV/Excel/COREP) on the
/calculatorform — when set, the background worker writes the selected formats to disk aftercalculate()returns (outsideSTAGE_SEQUENCE, so the live stepper and the stage count are unchanged) and the results page confirms exactly what was written; (2) a Save to folder form on the results page (POST /results/{run_id}/save) that re-exports an already-computed run to any folder without recomputing, looked up by its unguessablerun_id; and (3) real Download buttons on the results page wired to the existingGET /api/export/{fmt}endpoint (replacing the inert "use the REST API" text). All disk writing flows through one shared helper (ui/app/output_writer.py::write_selected_formats) that normalises the directory-vs-file asymmetry of the export wrappers and isolates every save in a run-stampedrwa_export_<run_id>subfolder, so a re-export can never silently clobber a different run's files and two concurrent writes cannot race on the same fixed filenames. A missingxlsxwriter(Excel/COREP) is reported per-format on the page rather than raising — the route never 500s on an export failure. The chosen folder/formats are remembered between sessions via a newcalculator_statelast-run file (mirroring the reconciliation form), and the calculator's framework / permission-mode / data-format selects now survive a validation bounce. A new non-raisingvalidate_output_path(sibling tovalidate_data_path) requires an absolute, resolvable path whose immediate parent exists (so a typo cannot create a deep tree), rejects Windows reserved device names, and treatsos.accessas advisory. Covered by new tests intests/unit/ui/test_output_writer.py,tests/unit/ui/test_calculator_state.py,tests/unit/api/test_api_validation.py(thevalidate_output_pathmatrix) andtests/integration/test_ui_app.py(calc-time write, the save route, graceful per-format failure, the form prefill and the non-destructive validation bounce). Plan:docs/plans/ui-output-folder.md.
Fixed¶
- CSV export no longer produces a blank
results.csv(and no longer silently skips the summary CSVs) when the result set contains nested columns. The full exposure-level frame carries a few nested columns (ancestor_facilities—List,securitisation_pool_allocations—List[Struct],addon_by_asset_class—Struct);ResultExporter.export_to_csvwrote them straight towrite_csv, which has no nested types — Polars createdresults.csv, then raisedComputeError: CSV format does not support nested data, leaving a 0-byte file and aborting before thesummary_by_class.csv/summary_by_approach.csvwrites. (Parquet was unaffected — it supports nested types — which is why selecting Parquet and CSV gave a populated Parquet but a blank CSV, and why the REST/api/export/csvtest had quietly excluded CSV.) The CSV writers now JSON-encode any nested column first, so every value is preserved as a JSON string (the cell round-trips through any CSV reader), the summary CSVs are written, andGET /api/export/csv+ the results-page CSV download + the new save-to-folder CSV all carry data. Covered bytests/unit/api/test_export.py::TestCsvNestedColumns, the REST export test now parametrizescsv, and the UI save tests assert a non-emptyresults.csv.
Changed¶
- The server-rendered UI app now enforces a loopback-only network posture, because letting a user-supplied string become a real filesystem write target reverses the deliberate "no user input reaches an FS path" stance of the REST export endpoints.
create_app()addsTrustedHostMiddleware(allowed_hosts=["localhost", "127.0.0.1"])(DNS-rebinding defence; the bind stays on127.0.0.1), and the disk-writing routes (POST /calculate,POST /results/{run_id}/save) require a same-origin request (require_same_originchecksOrigin/Sec-Fetch-Site) so a web page on another site cannot drive a write via a cross-origin form POST. The standalone REST app (create_api_app, used for tests/embedding) is unchanged — in the served UI the API routes run under the same middleware. This is safe only on the loopback single-user model the UI ships as; do not expose the app off127.0.0.1.
Documentation¶
- The blog was audited against the live codebase and brought current under a "snapshot-and-extend" policy, and a new
scripts/blog_counts.pymakes its headline figures reproducible. An audit found the eight-post series had drifted: headline counts were stale (the suite was cited at ~5,300 tests when it is now ~7,450; "eight architectural checks" when there are 17; "four agents" when there are 7; "eight pipeline stages" when there are 10), and the entire post-April epic — SA-CCR/CCR, SFT/FCCM, BA-CVA, securitisation allocation, the rulebook-as-data migration, and the FastAPI web UI/reconciliation — was unmentioned. The four published posts (2026-04-28 → 2026-06-23) are kept as honest dated snapshots: corrections land only in their dated Update notes, never by rewriting the body, preserving the pinned-commit narratives. The three future-dated unpublished drafts (2026-07-07, 2026-07-21, 2026-08-04) were brought current in place — including removing a false "BDD suite undertests/bdd/" claim (that directory is empty) and reworking the season-one finale's now-stale open-backlog scaffolding — and the finale is reframed as the end of "season one" rather than the end of the series. Four season-two posts were added (Counterparty Credit Risk / SA-CCR; the rulebook-as-data migration; the workbench-to-web-app reconciliation story; and a reviewer-gated-worktree follow-up to the agent-swarm post), with the series index (docs/blog/index.md) and nav restructured into season one / season two.scripts/blog_counts.pyemits the canonical project counts (test functions per pyramid layer, source/test file counts, arch-check count, role-agent count, pipeline-stage count) as a table, JSON, or Markdown, so future prose cites one reproducible source instead of hand-copied figures.
[0.3.5] - 2026-06-26¶
Added¶
- The reconciliation report is now a progressive-disclosure explorer that scales to millions of per-key rows instead of dumping the whole break worklist into one HTML page. Previously the report rendered seven tables in a single response and the break worklist (
collect_breaks_detail) was emitted uncapped — one<tr>per(key × component)break, every row pushed throughto_dicts()— so a large portfolio produced tens of MB of HTML held in server memory and shipped at once, while individual loans had no search, sort, paging, or drill-down (the forensic tier was hard-capped at 200 rows behind a whole-page bucket filter). The report now lands on an aggregates-only overview: the small pre-aggregated frames (headline tie-out, per-component summary, the segment tables) plus a ranked "Biggest breaks" top-N (scan_breaks_detail().head(N)) — it never collects the wide per-key frame, so it renders in constant time and constant DOM for any portfolio size. The full row-level diff is reached by drilling: each segment row links into a new per-key explorer (GET /reconciliation/{id}/rows) that filters (bucket / exposure class / approach / worst-component / key-substring), sorts (clickable headers,sort_bywhitelisted against the live component-dynamic schema →400on an unknown column) and pages server-side in Polars over the cached frame (clamped toMAX_PAGE_SIZE), so the browser only ever receives one page; and each key links into a new single-loan forensic (GET /reconciliation/{id}/loan?key=…, filter-pushdown on the lazy frame) that shows every component's legacy/ours/Δ/bucket plus the explain/input driver columns dropped from every on-screen table today (previously reachable only via the CSV export). All navigation is server-rendered query-param round-trips — no client framework — and the three re-inlined table markups are consolidated onto a sharedtemplates/_recon_table.htmlpartial. The CSV/Excel exports remain the full-data escape hatch. Covered by new view tests (tests/unit/ui/test_views_reconciliation.py— filter/sort/page/whitelist/loan-detail) and route tests (tests/integration/test_ui_reconciliation.py— overview→explorer→loan, the400sort guard, unknown-key404). Phase 2 ofdocs/plans/reconciliation-ux-redesign.md. - The reconciliation UI now shows the same live, stage-by-stage progress while a parallel run executes, so reconciling against a legacy file no longer looks like a frozen tab. Previously
POST /reconciliationranCreditRiskCalc.reconcile()synchronously on the request thread — strictly heavier than a calculation, because reconcile embeds a fullcalculate()run plus the legacy load and full-outer join — and only showed an indeterminate busy-overlay spinner. The reconciliation now runs on the same background-job machinery the calculator uses (src/rwa_calc/ui/app/progress.py): the form303-redirects immediately to a/reconciling/{job_id}stepper page (templates/reconciling.html), the embeddedcalculate()'s existingstage_timertelemetry streams every engine stage to the stepper for free, and a final "Reconcile & summarise" step (RECON_STAGE_SEQUENCE) is marked when the worker finishes. Crucially, the worker warms the lazy reconciliation frames — theReconciliationBundleis lazy, so its heavy join + bucketing otherwise fires on the firstcollect_*during the result-page render; warming on the worker thread keeps that compute honestly under the stepper and makes the subsequent report render hit the cache instead of freezing a second time. Progress streams over the existing Server-Sent Events endpoint (GET /jobs/{job_id}/events) with the JSON-poll fallback (GET /jobs/{job_id}); on completion the page navigates to/reconciliation/{job_id}(the job_id doubles as the recon result id via the newregister_reconciliation_with_id, mirroringregister_run_with_id). The shared stepper client (static/calculating.js) is generalised by onedata-result-baseattribute so it serves both the calculator (/results/) and reconciliation (/reconciliation/) pages with no SSE-shape change. Covered by updated/new tests intests/integration/test_ui_reconciliation.py(async dispatch → stepper → poll → report; SSE replay including the reconcile tail; unknown/reconciling/{id}404; the prefill/reset flows now wait for the background save). Design captured indocs/plans/reconciliation-ux-redesign.md(Phase 1 of 2; the large-result-set navigation rebuild is Phase 2). - The calculator UI now shows live, stage-by-stage progress while a run executes, so a calculation no longer looks like a frozen tab. Previously
POST /calculateblocked the request for the whole run (seconds to minutes on large portfolios) with no feedback, then redirected to the results page. The calculation now runs on a background worker (src/rwa_calc/ui/app/progress.py, a boundedThreadPoolExecutor) and the browser is redirected immediately to a/calculating/{job_id}stepper page. Progress is tapped from the pipeline's existing per-stage telemetry — every registered stage is already wrapped inobservability.stage_timer, which emits an INFO "<stage> completed" record carryingrecord.stage/record.elapsed_ms; alogging.Handlerattached to therwa_calcnamespace logger routes those records to the active job, correlated by acontextvarssink set inside the worker (no engine change, no frozen-dataclass mutation). The stepper is driven off stage order, never a synthesised percentage: the user watches the cheap lazy stages tick off a fixed 10-item checklist, then the spinner honestly parks on the heavycalculatorscollect (where the lazy-Polars compute is concentrated) rather than racing a bar to ~90% and hanging. Live updates stream over Server-Sent Events (GET /jobs/{job_id}/events) with automatic fallback to polling a JSON status endpoint (GET /jobs/{job_id}); on completion the page navigates to/results/{job_id}(the job_id doubles as the results id viaregister_run_with_id). The blocking comparison form gets an honest indeterminate busy overlay (spinner + live elapsed timer,static/busy-overlay.js). All new UI styling consumes the existing--oah-*tokens. Covered by new tests intests/integration/test_ui_app.py(async dispatch → poll → results; SSE stage replay + terminal; unknown-job 404s).
Changed¶
POST /calculate(the server-rendered UI route) is now asynchronous: it dispatches a background job and returns303 → /calculating/{job_id}instead of blocking until the run finishes and redirecting straight to/results/{run_id}. The RESTPOST /api/calculateis unchanged (still synchronous — the library-first contract external embedders consume). See the live-progress entry above.POST /reconciliation(the server-rendered UI route) is now asynchronous: it parses the mapping TOML synchronously (a bad mapping still re-renders the form with a400), then dispatches a background job and returns303 → /reconciling/{job_id}instead of blocking on the whole reconcile and redirecting straight to/reconciliation/{recon_id}. The form's last-run state is now saved by the worker on completion (so it survives the redirect). The RESTPOST /api/reconcileis unchanged (still synchronous). See the live-progress entry above.GET /reconciliation/{id}(the report page) no longer renders the full break worklist or the bucket-filtered forensic table; it is now the aggregates-first overview, and the per-key detail moved to the new/reconciliation/{id}/rowsexplorer and/reconciliation/{id}/loansingle-loan routes. The old?bucket=query param on the report page is gone (bucket is now one of the explorer's filters). See the progressive-disclosure entry above.
Fixed¶
- The UI no longer 404s on
public-files-sw.jsfor users who previously ran the old Marimo-based UI on the same port. The formerrwa-ui(Marimo) registered its service worker with a relative URL (register('./public-files-sw.js?v=2')), so the browser holds stale registrations at the root scope and any nested scope (/results/,/calculator/, …) of pages that were open — each re-fetching the script on every in-scope navigation, which the FastAPI app answered with 404 (the worker is harmless — Marimo's only intercepts/public/URLs — but it lingered and was noisy, and nested requests like/results/public-files-sw.jswere being swallowed by the/results/{run_id}route). The app now serves a self-unregistering tombstone worker at every…/public-files-sw.jspath (the two routes are registered ahead of the/{param}page routes so they win): the browser's update check for each scope installs it and it simply unregisters itself. It deliberately does not reload any tab — forcing a reload would also reload unrelated in-scope tabs (e.g. an old/results/<id>page from a previous server run, whose in-memory result is gone) and they would 404; instead each registration finishes uninstalling when its controlled pages next navigate or close. A no-op for browsers that never had the old worker. Covered bytests/integration/test_ui_app.py::test_stale_marimo_service_worker_is_tombstoned(root + nested scopes).
[0.3.4] - 2026-06-21¶
Fixed (Tier 8 — Counterparty Credit Risk; SA-CCR commodity same-commodity netting, CRR Art. 280c)¶
- The SA-CCR commodity add-on now nets trades referencing the same individual commodity into one effective notional before the within-bucket correlation, instead of treating every trade as a distinct commodity. Previously
engine/ccr/pfe.py::_compute_addon_commoditygrouped only by the fivecommodity_typebuckets and applied the ρ=0.40 idiosyncratic term per trade (sum_e²_b = Σ_i e_i²), so several trades on the same underlying were never fully offset — understating the add-on, and hence EAD, for same-direction books concentrated in one commodity. This is contrary to CRR Art. 280c / BCBS CRE52.68, where the individual commodity referencekis the unit of the formula (same-commodity legs net first; the ρ=0.40 partial correlation applies across distinct commodities within a bucket). A new nullablecommodity_referencecolumn onTRADE_SCHEMAidentifies the individual commodity; the add-on now nets each reference intoD_kfirst, then aggregatesAddOn_b = SF_CM[b]·√(ρ²·D_b² + (1−ρ²)·Σ_k D_k²)— mirroring how the credit / equity add-ons net byreference_entity. Fully backward-compatible: a nullcommodity_referencefalls back totrade_id, so each trade is its own reference and the prior per-trade result is reproduced bit-for-bit (the single-trade-per-bucket CCR-A7/A8/A9 goldens are unchanged). New unit tests intests/unit/ccr/test_commodity_reference_netting.py(same-reference netting raises the add-on for same-direction legs; equal-and-opposite legs on one commodity fully offset to zero; null reference preserves per-trade behaviour). (CRR Art. 280c; BCBS CRE52.68.)
Changed (Tier 8 — Counterparty Credit Risk; unmargined maturity-factor day-count, CRR Art. 279c(1))¶
- The unmargined SA-CCR maturity factor now measures residual maturity in business days on the 250-business-day year, consistent with the margined branch and the start-date floor.
engine/ccr/maturity_factor.py::compute_maturity_factor_unmarginedpreviously measuredMin calendar days over 365.25 (MF = √(min(M_cal, 1y)/1y)), inconsistent with the marginedMF = 1.5·√(MPOR/250)and the Art. 279b start floor (10/250), both on a 250-business-day year. CRR Art. 279c writes both maturity-factor branches against the same "1 year" denominator; because the margined MPOR is a business-day count, "1 year" = 250 business days throughout, so the unmargined residual maturity is measured in business days too. The SA-CCR adapter now suppliesbusiness_days_to_maturity(viapl.business_day_count, Mon-Fri, no holiday calendar) and the factor isMF = √(min(BD, 250)/250). The Art. 277(2) IR maturity buckets (1y / 5y thresholds) remain a calendar partition and are unaffected (they still readyears_to_maturity). Effect: the factor only moves for trades with residual maturity under ≈ 1 year (≥ 250 BD collapses toMF = 1.0); the 1-year unmargined goldens move to cleanMF = 1.0values (e.g. CCR-A2 EAD 4,478,466.54 → 4,480,000.00; CCR-A8 add-on 399,863.08 → 400,000.00; CCR-A5/A6/A10/D2 likewise). New unit tests intests/unit/ccr/test_mf_business_day_basis.py. (CRR Art. 279c(1); BCBS CRE52.50.)
Fixed (Tier 8 — Counterparty Credit Risk; unmargined maturity-factor 10-business-day floor, CRR Art. 279c(1))¶
- The unmargined SA-CCR maturity factor now floors the residual maturity at 10 business days, so it never falls below
√(10/250) = 0.20.engine/ccr/maturity_factor.py::compute_maturity_factor_unmarginedapplied the 250-BD cap (min(BD, 250)) but no lower floor, so a sub-10-business-day unmargined trade producedMF = √(BD/250) < 0.20— anti-conservative and contrary to the BCBS CRE52.47-52.48 (footnote 13) requirement that the maturity-factor residual maturityMbe floored at 10 business days. The factor is nowMF = √(min(max(BD, 10), 250)/250), sourced from a new regime-invariantmf_unmargined_floor_days = 10rulepackIntParam(cited CRR Art. 279c(1) / CRE52.47-52.48 fn.13). This 10-BD floor onMis distinct from the already-implemented Art. 279b 10-BD floor on the start dateSin the supervisory duration, and from the Art. 285 margined MPOR floors — same numeric value, different provisions on different quantities; a fresh pack scalar (not a reuse ofmf_margined_floor_days_otc) keeps the citation honest. No existing golden moves (every current CCR-A scenario is ≥ 10 BD to maturity); the floor is exercised by new cases intests/unit/ccr/test_mf_business_day_basis.py(BD ∈ {0,5,9,10} → 0.20, plus a date-driven 5-BD end-to-end check) and a pack-value test intests/unit/data/tables/test_sa_ccr_factors.py. (CRR Art. 279c(1); BCBS CRE52.47-52.48 fn.13.)
[0.3.3] - 2026-06-21¶
Fixed (Tier 8 — Counterparty Credit Risk; CCR/SFT IRB effective-maturity, CRR/PS1.26 Art. 162)¶
- Synthetic CCR / SFT exposure rows routed to IRB now receive the regulatorily-correct Art. 162 effective maturity (
M), and the maturity adjustment uses the actual sub-1-yearM. Previously an FCCM SFT (risk_type = "CCR_SFT") — or an SA-CCR derivative — that routed to IRB carried only amaturity_date; every other maturity driver (is_sft,has_one_day_maturity_floor,is_short_term_trade_lc) was null-filled to its schema default, so the IRB chain fell straight to the 1-year A-IRB catch-all and a repo-style F-IRB row never saw its fixedM = 0.5y. The FCCM SFT producer (engine/sft/fccm.py) now computes the Art. 162 maturity at netting-set grain and surfaces it on a new dedicatedccr_effective_maturityFloat64carrier (declared onCCR_EXIT_EDGE, propagated through the classifier / CRM / RE-split CCR edges; never via the lendingis_sftflag, which stays a CRM-only input). The IRB maturity chain (engine/irb/transforms.py) consumes it through a new AIRB-gated rung, setshas_one_day_maturity_floorfrom the winning rung (so the maturity adjustment uses the sub-1-yearMinstead of re-flooring to 1 year); the carrierMis already clamped to[floor, 5y]at the producer and is not re-clipped. The F-IRB 0.5-year repo-style supervisory maturity (CRR Art. 162(1)) reachesCCR_SFTrows via a widened gate,(is_sft OR risk_type == CCR_SFT);CCR_DERIVATIVErows are deliberately excluded from it. Regime-correct: under CRR a repo-style F-IRB row getsM = 0.5y; under Basel 3.1 (which blanks Art. 162(1)) it falls to the date-derivedM; the Art. 162(3) one-day floor and the 5BD/10BD MNA floors are floors (minimums) on the remaining maturity at a calendar/365day-count, gated on an explicit master-netting-agreement precondition and an explicit one-day-qualifying flag (absent ≠ daily — conservative). F-IRB coverage is proven end-to-end; A-IRB routing for CCR rows is a separate follow-up (an A-IRBCCR_SFTrow additionally needs an own-modelled LGD the FCCM producer does not yet emit — the carrier and the IRB rung are AIRB-ready, so closing that gap is purely additive). Also corrects three stale specs: the CRR F-IRB implementation note (thehas_one_day_maturity_floorflag does driveMto1/365, not only CRM maturity-mismatch ineligibility), the SFT spec's "two meanings of SFT" table (the lendingis_sftcarve-out is 0.5y under Art. 162(1), not "0.4-year / Art. 162(3)"), and the Basel 3.1 F-IRB note (Art. 162(3) retains "daily re-margining AND revaluation" under both regimes — only Art. 162(2A)(c)/(d) switched to "or"; the new Art. 162(2A)(da) mixed-MNA 10-day floor is documented). (CCR/SFT IRB-maturity Phases 0–6; CRR Art. 162(1)/(2)(c)(d)/(3); PS1/26 Art. 162(2)/(2A)/(3).)
Security¶
rulepack-diffCLI canonicalises and validates manifest paths before opening them (SonarQube path-injection hardening).rwa_calc.rulebook.audit.mainpassed its two operator-supplied positional path arguments straight through_load_manifestintoopen()with no validator on the dataflow, so SonarQube's taint analysis flagged the filesystem sink ("Agentic workflows should not be vulnerable to path injection attacks"). A new_safe_manifest_pathhelper now resolves each path to canonical absolute form (collapsing../ symlinks) and requires it to name an existing regular.jsonfile, raising a clearerror: ...SystemExitotherwise;_load_manifestopens the validatedPathit returns rather than the raw argument — mirroring the sanitiser-on-the-dataflow convention established for the argument-injection fixes. Containment to a fixed base directory is intentionally not enforced:rulepack-difflegitimately diffs per-runmanifest.jsonfiles written under the operator-chosenconfig.audit_cache_dir, which may sit anywhere on disk (the existingtmp_path-based CLI tests confirm cross-directory reads must keep working). Covered bytests/unit/rulebook/test_audit.py.worktree.pyname validator now sits on the taint dataflow path (SonarQube argument-injection follow-up)._validate_namepreviously returnedNoneand was called as a bare statement, so the original operator-suppliedname— not a sanitized value — kept flowing into_branch_for(name)/_worktree_path_for(name)and on into the shared_runsubprocess.runsink. SonarQube's taint analysis could not see a sanitizer on that path and flagged the sink ("Agentic workflows should not be vulnerable to argument injection attacks")._validate_namenow returns the validatednameand both call sites reassign (name = _validate_name(name)), matching thevalidate_git_ref/validate_semver/validate_iso_dateconvention already documented inscripts/_validate.py("return the validated value so the dataflow from source to subprocess sink passes visibly through the sanitizer"). Behaviour is unchanged — the^[a-z0-9][a-z0-9-]*$pattern already rejected leading dashes; this is the dataflow-visibility fix the prior entry's "name was already validated" note had glossed over.
[0.3.2] - 2026-06-20¶
Changed (SFT / FCCM separation — securities financing transactions promoted to a peer subsystem)¶
- Securities financing transactions (SFTs) are now a dedicated input + engine subsystem, separate from SA-CCR derivatives. Previously FCCM SFT EAD shared the SA-CCR derivative input schema and lived inside
engine/ccr/, discriminated only by a free-texttransaction_typestring — two unrelated regulatory EAD methods physically co-mingled. SFTs now have: a lean dedicated input contract (SFT_TRADE_SCHEMA+ optionalSFT_COLLATERAL_SCHEMA, the three Art. 223(5) exposure-haircut inputs now first-class instead of tunnelled), their ownsft_trades(+ optionalsft_collateral) dataloads loaded through the standard seal path, aRawSFTBundleonRawDataBundle.sft, anSFTConfig(withsft_methodexposed on.crr()/.basel_3_1()), and a dedicatedsft_fccmpipeline stage (engine/stages/sft.py) sitting immediately afterccr_sa_ccrin the literal registry. The Financial Collateral Comprehensive Method (FCCM) math —E* = max(0, E·(1+HE) − CVA·(1−HC−HFX)), CRR Art. 220–223 via Art. 271(2) — moved verbatim toengine/sft/fccm.py;engine/ccr/is now SA-CCR-derivatives-only (CRR Art. 274). The split keytransaction_typeis now value-constrained ({"derivative", "sft"}), so a mistyped discriminator raisesDQ006instead of silently mis-routing an SFT into the ≈£0-EAD derivative chain; the reservedvar(Art. 221) /imm(Art. 283) methods fail loud rather than dropping SFT rows. Fully backward-compatible:RawDataBundle.sftdefaultsNoneand thesft_fccmstage no-ops, so a firm with no SFT book is unaffected. The lendingis_sftBoolean (F-IRB maturity floor, Art. 162) is an unrelated concept and is deliberately left unchanged. New docs: a CCR-vs-SFT input section and a dedicated FCCM SFT specification. (SFT/FCCM Phases 1–6.) - FCCM SFTs now enter the Basel 3.1 output-floor S-TREA / U-TREA numerators (PRA PS1/26 Art. 92(3A)). The floor tag in
engine/stages/calc.pypreviously keyed only onrisk_type == "CCR_DERIVATIVE", so FCCM SFT rows kept the plainapproach_applied = "standardised"label and were excluded fromFLOOR_ELIGIBLE_APPROACHES— yet Art. 92(3A) does not place SFTs on the S-TREA exclusion list. The predicate now also matchesrisk_type == "CCR_SFT", so SFT rows receive the floor-eligiblestandardised_ccrtag and their SA-equivalent RWA enters the floor numerator (no double-count: the underlyingapproachcolumn and the plain-SA total are unchanged). CRR runs have no output floor and are unaffected. Pinned bytests/acceptance/ccr/test_ccr_floor2_sft_output_floor.py(B31-CCR-FLOOR-2: a £64.13m FCCM SFT enterss_trea = u_trea = £12.83munder the B3.1 institution 20% RW; pre-changes_trea = 0.0). (SFT/FCCM Phase 7a; PS1/26 Art. 92(2A)/(3A).) - FCCM SFT EAD is now reported under COREP C 07.00 row 0090 ("SFT netting sets"), not the SA-CCR templates (PS1/26 App. 17). Both CCR collectors (
reporting/corep/generator.py::_collect_ccr_rowsfeeding C 34.01/02/08, andreporting/pillar3/generator.py::_ccr_rowsfeeding CCR1/CCR8) summed allccr__-prefixed rows, so FCCM SFT EAD was mis-reported inside the SA-CCR derivative templates. Arisk_type != "CCR_SFT"exclusion was added to both collectors so SFT EAD leaves C 34 / CCR1 / CCR8, and the previously-unimplemented C 07.00 row 0090 is now populated (_filter_sft+ the C 07.00 SA-data selector now admitsCCR_SFTrows under both regimes so the SFT EAD lands in row 0090's exposure value (col 0200) and RWEA (col 0220) plus the class total row 0010 — SA-CCR derivatives still report under C 34). The reclassification conserves EAD: it appears once in C 07.00 row 0090 and zero times in C 34 / CCR1. The loan-only reporting golden oracle carries no SFT rows, so the 95 frozen reporting goldens are unchanged; the move is exercised by a new focused testtests/acceptance/reporting/test_reporting_sft_c07_0090.py(8 tests, both regimes). (SFT/FCCM Phase 7b; PS1/26 App. 17, CRR Art. 274/306.)
Added (Tier 8 — Counterparty Credit Risk; margined-SFT FCCM extension)¶
- FCCM SFT EAD now models margined securities financing transactions (CRR Art. 285 MPOR + Art. 226 non-daily revaluation). Previously
engine/sft/fccm.pypriced every SFT as unmargined, so a margined and an unmargined SFT produced identicalE*/ EAD / RWA. The applied supervisory haircut is now the fullH = H_10·√(T_M/10)·√((N_R+T_M−1)/T_M)(Art. 224(1) Table 1 base, Art. 224(2) period rescale, Art. 226 non-daily revaluation scale-up), with two mutually-exclusive branches selected on the newis_marginedflag: (a) unmargined / simply-collateralised uses the 5-business-day repo liquidation periodT_M = 5(Art. 224(2)(b)) and applies the Art. 226 factor driven byremargining_frequency_days(collapsing to 1.0 at daily revaluation); (b) margined (qualifying Art. 285(2)–(4) agreement) setsT_M = MPOR = F + N − 1(Art. 285(5)) and suppresses the Art. 226 factor because the MPOR already encodes the remargin period. The MPOR floorF(5 repo/sec-lending-only per Art. 285(2)(a), 10 other per Art. 285(2)(b), 20 for >5000-trade or illiquid sets per Art. 285(3)) and the Art. 285(4) ×2 dispute-doubling multiplier resolve from cited rulepack scalars — no regulatory numerics are hardcoded in the engine; an explicitmpor_days_overridesupersedes the derivation. Five new optionalSFT_TRADE_SCHEMAcolumns carry the inputs (is_margined,remargining_frequency_days,mpor_floor_category,has_margin_dispute_doubling,mpor_days_override), all defaulting so the unmargined-daily path is bit-identical to the prior behaviour (verified by IEEE-754 hex probe and the unchanged CCR-A11/A12 goldens). The margined mechanics are regime-invariant (CRR ≡ Basel 3.1 / BCBS CRE22); only the baseH_10table differs and is already pack-resolved. Reporting is unaffected: margining changes only the EAD magnitude — the synthetic row still carriesrisk_type = "CCR_SFT"/ccr_method = "fccm_sft", so FCCM SFTs continue to report under COREP C 07.00 row 0090 (PS1/26 App. 17) and stay out of the SA-CCR C 34 / CCR1 / CCR8 templates. Pinned bytests/unit/sft/test_margining_terms.py,tests/unit/sft/test_fccm_margined_branches.py,tests/unit/crm/test_liquidation_period_haircuts.py, and acceptance scenarios CCR-A15..A18 (tests/acceptance/ccr/test_ccr_a15_a18_margined_sft.py: unmargined daily 35,355.34, unmargined 3-day remargin 41,833.00, margined repo-only N=2 MPOR=6 38,729.83, FX-mismatch 601,040.76, margined + dispute-doubling MPOR=11 52,440.44). Also corrects stale citations within the SFT/FCCM subsystem: the Art. 224(2) period rescale (previously mis-cited as "Art. 226(2)") and the 5-BD repo period (previously "Art. 224(2)(c)", correct = Art. 224(2)(b)) inengine/sft/fccm.py,engine/sft/__init__.py, the two touchedengine/crm/haircut_tables.pydocstrings, and the CCR-A11/A12 / SFT spec docs. (The same mis-citation in the broader pre-existing CRM haircut path —engine/crm/haircuts.py,packs/common.py— is left for a separate codebase-wide pass.) (Margined-SFT Phases 0–4; CRR Art. 224(2)(b), Art. 226, Art. 285(2)–(5).)
Fixed (Tier 8 — Counterparty Credit Risk; P8.54 margined maturity-factor wiring)¶
- Margined SA-CCR netting sets now use the Art. 279c(2) margined maturity factor (
MF = 1.5·√(MPOR_eff/250)) instead of the unmarginedMF = √(min(M,1y)/1y).compute_maturity_factor_margined— with the full Art. 285 MPOR cascade (5/10/20-BD base, dispute doubling,+ remargining_frequency_days − 1, MPOR floor) — was implemented and unit-tested but never wired through the orchestrator:pipeline_adapterapplied the unmargined MF to every derivative trade regardless ofis_margined, leaving the two halves of Art. 279c inconsistent (only replacement cost reflected margining, per the earlier P8.19 fix). The pipeline adapter now denormalises the cascade inputs onto each trade and coalesces anis_margined-gated margined MF over the unmargined MF before the PFE add-on. Capital impact runs both directions: long-remargin sets (e.g. a 126-day CSA →MPOR_eff = 135→MF ≈ 1.10) were understated; normally daily-remargined sets (MF = 0.30) were overstated. Pinned by the re-derived CCR-A13 golden (daily remargin) and a new CCR-A14 long-remargin acceptance scenario. SA-CCR derivatives only — SFTs route to the FCCM haircut path and are unaffected. (P8.54; CRR Art. 279c(2), Art. 285.)
Removed¶
- Marimo workbench removed. The editable Marimo workbench — the
/workbenchpage and the on-demandmarimo editserver (port 8002), backed bysrc/rwa_calc/ui/marimo/— has been removed to reduce maintenance; equivalent interactive exploration is provided by the OpenAfterHours mooring project. The read-only UI pages (calculator, results, comparison, reconciliation) and the REST API are unchanged. Marimo remains a dependency for the expected-output workbooks underworkbooks/, which are unaffected. (src/rwa_calc/ui/marimo/,src/rwa_calc/ui/app/main.py)
Fixed (Parallel-run reconciliation — "ours" silently blank)¶
CreditRiskCalc.reconcile()no longer hides a failed "our" calculation behind a legacy-only "success". Previouslyreconcile()ranself.calculate()and scanned its results without checkingcalc_response.success; on any calculation failure (bad/empty data path, wrong format/framework,permission_mode='irb'with nomodel_permissions.parquet→ VAL003, or any pipeline exception) the error path writes a 0-row results parquet that still carries the reconciliation schema, so every legacy row reconciled asmissing_leftwithour_*blank,our_total = 0, and the response still reportedsuccess=Truewith an empty error list.reconcile()now short-circuits when the underlying calculation fails and surfaces the calculation's own errors on theReconciliationResponse(success=False). (src/rwa_calc/api/service.py)- New reconciliation diagnostics REC005 / REC006 so the two remaining silent "legacy feeds through, ours blank" modes are visible in the report instead of producing a normal-looking, legacy-only result with no warning. REC005 (
ERROR_RECON_NO_KEY_OVERLAP) fires when the legacy and our key columns share zero values (every row one-sided — almost always alegacy_keys/our_keysmapping mistake, or values differing by case / zero-padding / trailing whitespace /'123'vs'123.0'). REC006 (ERROR_RECON_NON_FINITE_VALUE) fires when the our side carries aNaN/infvalue in any numeric component — a single non-finite value poisons that component's portfolio total and tie-out (Decimal('NaN')), blanking "ours" even though most rows reconcile; the warning names the affected component(s) and points the analyst at the upstream calculation. Both are non-fatalWARNING/DATA_QUALITYrecords, consistent with the existing REC001–REC004 family. (src/rwa_calc/analysis/reconciliation.py,src/rwa_calc/contracts/errors.py)
Added (diagnostics)¶
scripts/diagnose_rwa_nonfinite.py— a read-only CLI that runs a calculation and lists every exposure whoserwa_final/ead_final/risk_weightis non-finite (NaN/inf), printing the input/driver columns (PD, LGD, maturity, EAD components, …) and flagging which of them are themselves non-finite, so a portfoliototal_rwa = NaNcan be traced to the handful of offending exposures (e.g. a very low/zero PD hitting the IRB maturity-adjustment denominator(1 − 1.5·b)crossing zero, or aNaNinput).
Added (Tier 8 — Counterparty Credit Risk acceptance coverage; batch 20260619-1550)¶
- New BA-CVA intra-counterparty cross-netting-set SCVA aggregation acceptance coverage (PRA PS1/26 App.1 CVA Part §4.3
SCVA_cper-netting-set summation; §4.2 single-counterparty K-collapse). Acceptance scenario CVA-A3 pins existing-and-correct BA-CVA behaviour (regression guard — no engine change): the first case exercising the inner per-netting-set summationSCVA_c = (1/α)·RW_c·Σ_NS[M_NS·EAD_NS·DF_NS]across two netting sets of a single counterparty (CVA-A1 used one netting set, where the sum is trivial; CVA-A2 used two counterparties, exercising the inter-counterparty ρ=0.5 cross-term). One FINANCIAL/IG counterparty carries two unmargined IR-swap netting sets (3y / 5y effective maturity, soEAD_NS1 ≠ EAD_NS2); the expectedcva_rwais derived dynamically from the live SA-CCR netting-set EADs via the samerulebook/packs/b31.pyscalars the engine reads (ds_ba_cva=0.65,cva_ba_supervisory_discount_rate=0.05,cva_ba_supervisory_risk_weightsFINANCIAL/IG=0.05,sa_ccr_alpha=1.4,own_funds_to_rwa_factor=12.5). Load-bearing EAD-robust invariants: cross-netting-set additivitySCVA_c = SCVA_NS1 + SCVA_NS2(pinning the engine'sgroup_by("counterparty_reference").agg(Σ per-NS term)) and the single-counterparty collapseK_reduced = SCVA_c(ρ cancels). Pinned bytests/acceptance/ccr/test_ccr_ba_cva_a3.py(7 tests; P8.46 — BA-CVA subset; the SA-CVA scenarios in P8.46 remain blocked on the v2.0-deferred P8.61).
Added (Tier 8 — Counterparty Credit Risk acceptance coverage; batch 20260619-1521)¶
- New BA-CVA multi-counterparty diversification acceptance coverage (PRA PS1/26 App.1 CVA Part §4.2 reduced-K formula; ρ=0.5 supervisory correlation). Acceptance scenario CVA-A2 pins existing-and-correct BA-CVA behaviour (regression guard — no engine change): the first two-counterparty case where the reduced-K aggregation
K_reduced = √[(ρ·ΣSCVA)² + (1−ρ²)·ΣSCVA²]genuinely exercises the ρ=0.5 systematic cross-term (every prior CVA pin used a single counterparty, where K_reduced collapses toSCVA_cand ρ never bites). Two FINANCIAL/IG counterparties (3y / 5y effective maturity) each carry one unmargined IR-swap netting set; the expectedcva_rwais derived dynamically from the live SA-CCR netting-set EADs via the samerulebook/packs/b31.pyscalars the engine reads (ds_ba_cva=0.65,cva_ba_supervisory_correlation=0.5,cva_ba_supervisory_discount_rate=0.05,cva_ba_supervisory_risk_weightsFINANCIAL/IG=0.05), plus an EAD-robust structural invariant√(SCVA₁²+SCVA₂²) < K_reduced < SCVA₁+SCVA₂that pins ρ independent of absolute EAD. Pinned bytests/acceptance/ccr/test_ccr_ba_cva_a2.py(5 tests; P8.46 — BA-CVA subset; the SA-CVA scenarios in P8.46 remain blocked on the v2.0-deferred P8.61).
Fixed (Tier 8 — Counterparty Credit Risk; batch 20260619-0822)¶
- Failed/unsettled DvP-trade RWA now reaches firm totals (CRR Art. 378; conversion Art. 92(3)(ca)).
compute_failed_trade_rwawas implemented (P8.24) but never invoked in the pipeline, so settlement-risk RWA was silently omitted from the aggregated totals. It is now wired through the CCR stage as synthetic SA exposure rows (risk_type='SETTLEMENT_FAILED_TRADE',risk_weight=12.5read from the common pack'sown_funds_to_rwa_factor), applying the Art. 378 Table 1 escalating multiplier ladder (8% / 50% / 75% / 100% by business days past settlement). Pinned bytests/acceptance/ccr/test_ccr_c1_c3_failed_trades.py(CCR-C1/C2/C3; P8.43). - SA-CCR EAD now contributes to the output-floor S-TREA / U-TREA numerators (PRA PS1/26 Art. 92(3A)). SA-routed CCR exposures were tagged
approach_applied='standardised'and thus excluded fromFLOOR_ELIGIBLE_APPROACHES, so a CCR-only portfolio produceds_trea = u_trea = 0— yet Art. 92(3A) does not exclude SA-CCR from S-TREA. CCR rows are now re-tagged into a floor-eligible approach (CCR-specific; ordinary SA exposures still cancel out of S-TREA) with the total RWA unchanged (no double-count). Pinned bytests/acceptance/ccr/test_ccr_floor1_output_floor.py(B31-CCR-FLOOR-1; P8.55).
Added (Tier 8 — Counterparty Credit Risk; batch 20260619-0936)¶
- Clearing-member default-fund-contribution RWA (CRR Art. 308 / 309). Firms with pre-funded (or unfunded) contributions to a CCP's default fund can now compute that capital in-system; previously the contribution was silently omitted from RWA totals. New
engine/ccr/default_fund.pyallocates the firm's shareK_CM = K_CCP × DF_i / DF_CM(Art. 308(2) clearing-member allocation) and converts it to RWEA via the 12.5× own-funds→RWA factor read from the rulepack packown_funds_to_rwa_factor(CRR Art. 92(3)(ca)) — pre-funded QCCP per Art. 308(3), non-QCCP / unfunded per Art. 309(2). Contributions are supplied through a new optionaldefault_fund_contributionsinput onRawCCRBundle(DF_CONTRIBUTION_SCHEMA) and surfaced as a newrwa_ccr_default_fundroll-up onAggregatedResultBundle; internally they ride the CCR stage as synthetic SA exposure rows pinned at RW 12.5. The CCP's hypothetical capitalK_CCPis a firm-supplied input (the loss-mutualisation simulation is out of scope). Pinned bytests/acceptance/ccr/test_ccr_b2_b4_default_fund.py(CCR-B2 direct-cleared QCCP 12,500,000 / CCR-B3 non-QCCP 9,375,000 / CCR-B4 unfunded non-QCCP 5,000,000; portfolio delta 26,875,000; P8.49).
Added (Tier 8 — Counterparty Credit Risk acceptance coverage; batch 20260619-1022)¶
- New SA-CCR default-risk acceptance coverage (CRR Art. 107(2)(a) / 120 / 122 / 114, 274–282; PRA PS1/26 institution ECRA). Three regulatory acceptance suites were added to pin existing-and-correct SA-CCR behaviour (regression guards — no engine change): CCR-B5 — a non-QCCP CCP trade exposure demotes to the institution SA ladder (Art. 107(2)(a) → Art. 120(1) Table 3, CQS-1 → 20%), distinct from the CQS-2 → 50% band already covered (
tests/acceptance/ccr/test_ccr_b1_b5_ccp.py; P8.42); CCR-D1..D3 — sub-threshold portfolios fall through to full SA-CCR rather than the v2.0-deferred Simplified SA-CCR (Art. 281) / OEM (Art. 282), with CCR-D3's margined-OTM PFE multiplier0.6048…pinning that no Art. 281 forced-multiplier=1.0 branch exists (tests/acceptance/ccr/test_ccr_d1_d3_simplified_oem_fallthrough.py; P8.44); CCR-E1..E5 — one SA-CCR EAD routes to the correct SA risk weight per counterparty class under both CRR and Basel 3.1 (institution 50% → 30% ECRA, corporate 100% → 75%, foreign sovereign 50%), with EAD-invariance and CRR↔B3.1 RW-delta cross-checks (tests/acceptance/ccr/test_ccr_e1_e5_default_risk_routing.py; P8.45).
Added (Tier 8 — Counterparty Credit Risk; batch 20260619-1112)¶
- BA-CVA (Basic Approach) reduced-version CVA-risk RWA now computed and surfaced (PRA PS1/26 App.1 CVA Part §4.2–4.4; own-funds→RWA ×12.5 per Own Funds Part §4(b)). Firms can now obtain Basic-Approach CVA capital for SA-CCR derivative counterparties — previously no CVA framework existed in-system. New
engine/cva/ba_cva.pycomputescva_rwa = DS_BA_CVA(0.65) × K_reduced × 12.5, whereK_reduced = √[(ρ·ΣSCVA)² + (1−ρ²)·ΣSCVA²](ρ=0.5, collapsing toSCVA_cfor a single counterparty) andSCVA_c = (1/α)·RW_c·Σ_NS[M_NS·EAD_NS·DF_NS]with α=1.4 andDF_NS=(1−e^(−0.05·M))/(0.05·M), reusing the SA-CCR netting-set EAD from the syntheticccr__*rows. The mandatory PRADS_BA_CVA = 0.65discount scalar (source-verified againstdocs/assets/ps126app1.pdfp399), ρ, the 0.05 discount rate and the §4.4 sector × IG/HY-NR supervisory risk-weight table live inrulebook/packs/b31.py(each cited), gated by the Basel-3.1-onlycva_ba_cvapack feature (nois_basel_3_1branch). New optionalRawDataBundle.cva_counterpartiesinput (CVA_COUNTERPARTY_SCHEMA) andAggregatedResultBundle.cva_rwaoutput, both defaulting toNoneso non-CVA runs are byte-identical. Scope: reduced-K single-counterparty slice only —K_full/ hedge recognition (Art. 386, P8.62), aggregated-bundle CVA fields (P8.63), SA-CVA (P8.61) and multi-counterparty diversification remain follow-ups. Pinned bytests/acceptance/ccr/test_ccr_ba_cva_a1.py(CVA-A1:cva_rwaderived dynamically from the pipelineead_ccr; P8.60).
Added (Tier 8 — Counterparty Credit Risk; batch 20260619-1215)¶
- Full BA-CVA eligible-hedge recognition (PRA PS1/26 App.1 CVA Part §4.5–4.10; Art. 386 eligible single-name / index CDS). Extends the reduced BA-CVA (P8.60) to the full version
K_full = β·K_reduced + (1−β)·K_hedged(β hedging-disallowance weight 0.25, §4.5) that nets eligible CVA hedges against stand-alone counterparty CVA capital. New firm-suppliedcva_hedgesinput (CVA_HEDGE_SCHEMA— single-name / index CDS carrying counterparty attribution, supervisory correlation band, sector/rating RW keys, residual maturity, notional and an eligibility flag) wired as an optionalRawDataBundlefield + loader edge, mirroringcva_counterparties. Source-verified againstdocs/assets/ps126app1.pdf: the single-name-hedge termSNH_c = Σ_h(r_hc·RW_h·M_h·B_h·DF_h)carries no(1/α)factor (§4.7), unlikeSCVA_c = (1/α)·RW_c·M_NS·EAD_NS·DF_NS; the indirect-hedge-misalignment (HMA, §4.9) and index-hedge (IH, with the 0.70 diversification factor, §4.8) terms are implemented in full. New citedrulebook/packs/b31.pyentriescva_ba_beta(0.25, §4.5),cva_ba_single_name_hedge_correlation(r_hc {IDENTICAL 1.00 / LEGALLY_RELATED 0.80 / SAME_SECTOR_REGION 0.50}, §4.10) andcva_ba_index_diversification_factor(0.70, §4.8); the Basel-3.1-onlycva_ba_cvafeature gate is unchanged. When no eligible hedges are supplied the charge is byte-identical to the reduced version (back-compat; the existing P8.60 / CVA suite stays green). Pinned bytests/acceptance/ccr/test_ccr_cva_hedge_a1.py(CVA-HEDGE-A1: a perfect single-name hedge — notional = EAD/α, r_hc = 1.0 →K_hedged= 0 — collapses the charge to exactly β × the reduced RWEA, i.e.cva_rwa_full / cva_rwa_reduced == 0.25, derived from the live SA-CCR EAD; P8.62).
Added (Tier 8 — Counterparty Credit Risk; batch 20260619-1305)¶
- Aggregated CVA surface on
AggregatedResultBundle—cva_methodandcva_hedges_recognised(PRA PS1/26 App.1 CVA Risk Part §4.2–4.10; CRR2 Art. 384 Basic Approach; own-funds→RWA ×12.5 per Own Funds Part §4(b)). The BA-CVA charge (P8.60 reduced / P8.62 full) previously surfaced only as the scalarcva_rwa, with no machine-readable record of which CVA approach ran or whether eligible hedges were recognised — exactly the metadata the COREP / Pillar-III CVA templates (P8.50 / P8.51) consume. Two descriptive fields are now populated alongsidecva_rwain the singleengine/stages/aggregate.py::_ba_cva_roll_uppath:cva_method("BA-CVA"for both the reduced and full Basic Approach;Nonewhen CVA is out of scope) andcva_hedges_recognised(Truewhen ≥1 eligible hedge fed the full-Kpath,Falsefor the reduced path,Noneout of scope).compute_ba_cva_rwanow returns a typedBaCvaResult(rwea, hedges_recognised)NamedTuple so the recognition flag reuses the exactcva_hedge_eligiblediscriminator already driving the full-vs-reduced branch — single source of truth, no parallel CVA computation. The portfolio total composes additively as default-risk RWA + CVA RWA (the SA-CCRccr__*default-risk rows are summed intoΣ rwa_final;cva_rwaadds on top and is not double-counted). No@citesdecorator (the CVA Part articles are outside watchfire's bundled CRR index — docstring attribution, consistent with theengine/ccr/NOTE-waiver pattern). Back-compat: non-CVA runs keep all three fieldsNone. Pinned bytests/acceptance/ccr/test_ccr_cva_aggregated_p8_63.py(CVA-AGG-A1: reduced →BA-CVA/False, full →BA-CVA/True, ratiocva_rwa_full / cva_rwa_reduced == 0.25, theΣ rwa_final + cva_rwacomposition identity, and an out-of-scope all-Nonecontrol; 9 tests; P8.63).
Added (Tier 8 — Counterparty Credit Risk; batch 20260619-1334)¶
- CCR reporting roll-ups surfaced on
AggregatedResultBundle—ead_ccr_total,rwa_ccr_default,rwa_ccr_qccp_trade,failed_trades_rwa(CRR Art. 274(2) total SA-CCR EAD; Art. 107(2)(a) non-QCCP default-risk RWA; Art. 306(1)/(4) QCCP trade-leg RWA; Art. 378–380 / 92(3)(ca) settlement-risk RWA). The CCR stage already computed default-risk, QCCP trade-leg and failed-trade RWA per synthetic row, but the only portfolio-level CCR scalars on the output bundle wererwa_ccr_default_fund(P8.49) andcva_rwa(P8.63) — the COREP (P8.50) and Pillar-III (P8.51) CCR templates had no bundle field to read total CCR EAD or the default-risk / QCCP-trade / settlement split. Four newfloat | Nonefields are now populated inengine/aggregator/aggregator.pyas filtered sums over the already-materialisedcombined_df(no new.collect()):ead_ccr_total= Σead_finalover the syntheticccr__rows;rwa_ccr_default/rwa_ccr_qccp_tradepartition Σrwa_finalover those rows by the QCCP trade-leg discriminator (cp_entity_type == "ccp"ANDcp_is_qccpfilled-true, mirroring the SA QCCP override) so the two reconcile exactly to the fullccr__rwa_finalsum;failed_trades_rwa= Σrwa_finaloverSETTLEMENT_FAILED_TRADErows. Each is column-presence-guarded and staysNoneon a CCR-free portfolio, so non-CCR runs are byte-identical and per-rowrwa_final/ total TREA are untouched. Unblocks the bundle reads for P8.50 / P8.51. Pinned bytests/acceptance/ccr/test_ccr_p852_reporting_rollups.py(CCR-E1 EAD/default sums, QCCP 2%/4% trade-leg, failed-trade sum, the default+QCCP reconciliation invariant, and the empty-portfolio all-Nonecontrol; 16 tests; P8.52).
Added (Tier 8 — Counterparty Credit Risk reporting; batch 20260619-1411)¶
- COREP CCR templates C 34.01/02/04/08 (COREP Annex II / Regulation (EU) 2021/451; CRR Art. 274(2) SA-CCR EAD, Art. 306(1)/(4) QCCP 2%/4% trade-leg, Art. 107(2)(a) non-QCCP default-risk; PRA PS1/26 App.1 CVA Part §4.2–4.4 BA-CVA). Firms can now produce the counterparty-credit-risk COREP grid: C 34.01 (analysis by approach — SA-CCR EAD + RWEA roll-up), C 34.02 (SA-CCR EAD per netting set), C 34.04 (CVA capital — BA-CVA RWEA,
Noneunder CRR) and C 34.08 (CCP exposures — QCCP proprietary 2% / client-cleared 4% trade legs, non-QCCP, default-fund). These are a pure reshape of theAggregatedResultBundleCCR roll-up columns (P8.52/P8.63) via newCOREPTemplateBundle.c34_*fields +_generate_c34_*methods inreporting/corep/; the QCCP/non-QCCP partition byte-mirrors the aggregator discriminator. The portfolio BA-CVA RWEA — a bundle-only scalar — is surfaced to the COREP consumer as an optionalcva_rwacolumn onAGGREGATOR_EXIT_EDGE(contracts/edges.py), broadcast byengine/stages/aggregate.pyand re-sealed so C 34.04 reads it from the results LazyFrame. The IMM (C 34.03), IRB-CCR (C 34.07/34.11), collateral-composition (C 34.05), top-10 (C 34.06) and RWEA-flow (C 34.09/34.10) grids are deferred structure-only (no IMM / IRB-CCR-routing / collateral-roll-up / prior-period support in the engine yet). Pinned bytests/unit/test_corep_ccr.py(17 tests; QCCP/CVA cell values asserted as invariants over the golden p839-CCP and CVA-A1 fixtures; P8.50). - Pillar III CCR disclosure tables CCR1/CCR2/CCR3/CCR8 (PRA Disclosure (CRR) Part / PS1/26 Annex XXII; CRR Art. 274(2), Art. 306, Art. 120(1) Table 3; PS1/26 CVA Part §4.2–4.4). Firms can now produce the public CCR disclosures: CCR1 (analysis by approach), CCR2 (BA-CVA capital charge), CCR3 (SA-CCR EAD by risk-weight band) and CCR8 (CCP exposures, QCCP vs non-QCCP) — CRR uses the UK table-code prefix, Basel 3.1 uses UKB. A reshape of the same
AggregatedResultBundleCCR roll-ups via newPillar3TemplateBundle.ccr*fields +_generate_ccr*inreporting/pillar3/; CCR3 reuses the existing CR5 risk-weight bands and CCR2 reads the same sharedcva_rwabroadcast column as COREP C 34.04 (single source of truth — no duplicate CVA column). CCR4 (IRB EAD by PD), CCR5 (collateral composition), CCR6 (credit derivatives) and CCR7 (IMM RWEA flow) are deferred structure-only. Pinned bytests/unit/test_pillar3_ccr.py(16 tests; roll-up invariants over the golden CCR-A1 / p839 / CVA-A1 fixtures; P8.51).
Security¶
- Developer scripts validate operator CLI input before it reaches
subprocessargv (SonarQube subprocess hardening). A newscripts/_validate.pyprovides fail-fast validators —validate_semver(strictN.N.N),validate_git_ref(safe-commitish allowlist that rejects leading-,..,@{, trailing.lock//, and whitespace), andvalidate_iso_date— wired in at the argparse boundary ofdeploy.py(theversionpositional),worktree.py(the--frombase ref; the worktreenamewas already validated), andprofile_memory.py(the--dateflag, validated in the parent before the workerPopen). All call sites already used the argv-list form (nevershell=True), so this is defence-in-depth: a malformed or hostile argument is now rejected with a clear error before any command is built. Covered bytests/unit/test_validate.py.
[0.3.1] - 2026-06-18¶
Fixed (migration Phase 7 S1 — reporting sealed-input reconciliation)¶
- COREP C 08.02 / C 08.03 / C 08.05 and Pillar 3 CR6 / CR9 no longer emit empty from real sealed pipeline output. The generators probed fictional
irb_pd_floored/irb_pd_original(andirb_lgd_floored/irb_lgd_original) PD/LGD columns that the engine never produces — the sealedAGGREGATOR_EXITcarriespd_floored/pd/lgd_floored/lgd_input. The probes missed, so these IRB PD/LGD-keyed templates silently produced nothing in production (the synthetic unit fixtures masked this by pinning the fictional columns). The generators now read the sealed canonical names directly (operator-chosen Option B — reporting reads the sealed exit, no new contract aliases). Also fixed the same class of miss in the C 08.01 col 0010 EAD-weighted PD, C 09.02 col 0080 EWA PD, and the LFSE memo cells (apply_fi_scalar→ the sealedcp_apply_fi_scalar). CR9.1 remains intentionally empty (gated on an ECAI PD-mapping disclosure the engine does not produce — recorded accept-empty). - Equity now surfaces in reporting. The reporting oracle portfolio gained one listed equity holding; the aggregator already concatenates the equity frame into
result.resultsbefore the seal, so anapproach_applied='equity'row reaches the COREP/Pillar 3 generators and contributes to C 02.00 / OV1 / output-floor totals. (The prior "equity does not reach results" note was stale — corrected.)
Changed¶
- Reporting generators: dead alias
_pickrungs removed. Consumer-side alias ladders for columns the sealed contract never carries (final_ead,final_rwa,sa_final_risk_weight,sa_equivalent_rwa,ccf_applied,lgd_final, bareinternal_rating_grade, thedefault_statusrung off theis_defaultedladder, the redundantirb_expected_lossfirst rung) are deleted;rwa_before_sme_factorretargets to the sealedrwa_pre_factor. Sealed fallbacks (rwa_post_factor/rwa) are retained. Reporting goldens (tests/expected_outputs/reporting/) re-captured for both regimes with each diff verified as a legitimate fix. A new regression suite (tests/acceptance/reporting/test_reporting_s1_reconciliation.py) locks the non-empty templates + equity surfacing.
[0.3.0] - 2026-06-17¶
Changed (architecture — migration Phase 6: analysis/ layer) — BREAKING for direct comparison/reconciliation API consumers¶
- Comparison, reconciliation and transition move into a new top-level
analysis/layer.engine/comparison.py→analysis/comparison.py,engine/reconciliation.py→analysis/reconciliation.py,TransitionalScheduleRunner→analysis/transition.py; the reconciliation registry (RECONCILABLE_COMPONENTS/ReconcilableComponent), theLegacyColumnMapping/ComponentMappingconfig andReconciliationRunnerProtocolmove intoanalysis/recon_registry.py/analysis/reconciliation.py— out ofdata/schemas.pyandcontracts/config.py, severing thecontracts→registrylayering knot. Direct importers must repointrwa_calc.engine.{comparison,reconciliation}andrwa_calc.contracts.config.{LegacyColumnMapping,ComponentMapping}torwa_calc.analysis.*.arch_checkpre-declaresanalysis/aboveengine/(downward imports only). - Comparison is generalised to a labelled two-run over rulepack-identified runs (BREAKING). New
RunSpec(config, label, rulepack=None);DualFrameworkRunner.compare(data, baseline, variant)accepts a bare config (label defaults to itsregime_id) or aRunSpec, threading a rulepack overlay into the pipeline — unlocking reversed-regime, election-vs-election and regime-vs-amended comparisons. The CRR/B31 framework gate (_validate_configs) is replaced by a distinct-label check.ComparisonBundle.crr_results/b31_resultsare renamedbaseline_results/variant_results(plusbaseline_label/variant_label); the CRR-vs-B31 column names and numbers are unchanged. - The CRR→B31 capital-impact waterfall becomes one registered delta-attributor pairing.
analysis/attribution.pyholds a registry keyed on the run pairing plus a regime-agnostic neutral delta-only fallback; the four-driver waterfall registers under('crr','b31')(byte-identical for the framework comparison). Any unregistered pairing gets the neutral attributor. - Transitional floor-schedule: floor-tail partial re-run assessed and recorded as infeasible. Per-year pre-floor IRB RWA is reporting-date-dependent via effective maturity (CRR Art. 162), so a floor-tail-only re-run would produce wrong per-year results; the full per-year pipeline run is retained (documented in
TransitionalScheduleRunner).
Changed (architecture — migration Phase 5: rulebook — regime as versioned, citation-carrying data) — BREAKING for direct config consumers¶
- The regime is now data, not code. A new
rwa_calc.rulebookpackage is the single carrier of regulatory variation:model.py(ten frozen, Decimal-valued, citation-required rule shapes —ScalarParam,IntParam,DateParam,CategoryMap,LookupTable,BandedTable,Schedule,DecisionTable,FormulaParams,Feature),packs/{common,crr,b31}.py(the values, each with a mandatory CRR / PS1-26 citation),resolve(regime_id, reporting_date)→ a frozen, content-hashedResolvedRulepack, andcompile.py(pack → Polars expressions, the only Decimal→float boundary). Regime-divergent behaviour is selected by a cited packFeature(pack.feature(...)); transitional / effective-date logic resolves throughScheduleentries atresolve()time, so the engine never comparesreporting_dateto a regulatory date to pick behaviour. data/tables/is deleted. Every regulatory value (SA risk weights, CRM collateral haircuts, PD/LGD floors, supervisory LGDs, CCFs, slotting/equity tables, SA-CCR factors, LTV bands, the CRR Art. 153(1) 1.06 scaling factor, …) now lives in the rulepack packs and is read back viaresolve. The three relocated table-builder modules survive as thin pack-binding shims underengine/(engine/sa/crr_risk_weight_tables.py,engine/sa/b31_risk_weight_tables.py,engine/crm/haircut_tables.py);src/rwa_calc/data/now holds onlycolumn_spec.py+schemas.py(input-domain validation enums / category maps stay inschemas.py).- Regulatory values are removed from the config object (BREAKING). The per-run config now carries firm inputs + elections + a
regime_id(str) and zero regulatory values. Thescaling_factor,pd_floors,lgd_floors,supporting_factorsandthresholdsfields — and thePDFloors/LGDFloors/SupportingFactors/RegulatoryThresholdsdataclasses — are gone; those values resolve from the pack (monetary thresholds viaengine/thresholds.py, which appliesEUR base × eur_gbp_rateat read time, witheur_gbp_ratekept on the config as a market input).RunConfigis introduced as the canonical per-run name (currently a transparent alias,RunConfig = CalculationConfig; the class is retained for back-compat)..crr()/.basel_3_1()remain as named constructors, now settingregime_id;framework/is_crr/is_basel_3_1survive as derived read-only properties for non-engine consumers. - The engine no longer branches on the framework. The ~62
config.is_crr/config.is_basel_3_1reads across the SA / IRB / slotting / equity calculators, classifier, CRM, CCF, RE-split and CCR stages are replaced by cited packFeatures, and the two regime-state constructor classes (CRMProcessor,HaircutCalculator) lost theiris_basel_3_1flag. New / tightened arch_check gates make this permanent: check 17 bansconfig.is_crr/config.is_basel_3_1reads inengine/**; check 12 is now a zero-tolerance hard ban onengine/**importingrwa_calc.data.tables; and a newcheck_no_numeric_tables_in_engineguards against module-level float-rate tables re-entering the engine. - Auditability is structural. The run manifest records the rulepack id + content hash + the full serialised resolved parameter set with citations; a new
rulepack diffCLI (rwa_calc/rulebook/audit.py) materialises the regulatory delta between two regimes as a reviewable artifact; watchfire@citescoverage now extends to pack data (validated byarch_check). - Duplicates single-sourced; a latent trap removed. The 1.06 scaling factor (×4 sites), the Art. 161 supervisory LGDs (two formats collapsed to one canonical
DecisionTable), the collateral-haircut dict↔DecisionTable duplication, and the life-insurance Art. 232 RW map (a dead{float: float}dict that had drifted from the production expression) each collapse to a single cited pack home; theCOVERED_BOND_UNRATED_DERIVATIONunsuffixed-alias trap is deleted (it was a latent risk, not a live bug — no CRR number ever borrowed the B31 value). - Parity: every slice (S1–S13) was gated byte-identical across all four 10k stress configs (crr_sa / crr_irb / b31_sa / b31_irb) vs the pre-phase baseline (
../rwa_phase5_parity/before); full suite 7506 passed / 2 skipped at close. The slice-group narrative and every recorded preserve-or-fix decision are indocs/plans/target-architecture-migration.md(Phase 5, §6 decisions S1–S13). Deliberately deferred (recorded): theccr/sft_fccm.pyregime-insensitive SFT haircut (number-changing) and the regime-invariant formula-embedded constants kept inline per the S5d precedent.
Changed (architecture — migration Phase 4: uniform stage model)¶
- The pipeline is now a fold over a literal stage registry.
engine/registry.pyholds the single ordered, literal stage list (nineStageSpecentries — one screen, no conditionals);engine/orchestrator.pyprovides the pure foldrun_stagesthat threads an immutablePipelineContext(contracts/context.py: typedArtifactKey[T]artifact map) through the stages under per-stagestage_timers, with declared per-stage failure policies (verbatim ports of the pre-fold behaviour). Each stage is onerun(ctx, rulepack, run_config) -> ctxadapter module underengine/stages/wrapping today's class-shaped component.PipelineOrchestrator(engine/pipeline.py, 1,194 → ~520 LOC) survives as thePipelineProtocolfacade owning the run lifecycle (run_id, edge capture, FX-rate sync, error merge, audit persistence) — zero churn for the ~90 test files that driverun_with_data. Parity: byte-identical across all four 10k stress configs. - The final stage signature is frozen:
Stage(ctx, rulepack, run_config).rwa_calc.rulebooklands withRulepackV0— a frozen facade over today'sCalculationConfig(regime id,is_crr/is_basel_3_1, the canonical CRR Art. 153(1)scaling_factor) built once per run after the EUR/GBP FX-rate sync finalises the effective config. Phase 5 swaps the implementation, not the signature. arch_check check 12 gains therulebooklayer (imports contracts/data/domain; never engine/api/ui/reporting/analysis). - Orchestrator scratch state is gone. The cross-stage
self._securitisation_resolved/self._errors/self._ccr_errorsattributes are replaced by typed context artifacts (SECURITISATION_RESOLVED,PIPELINE_ERRORS,CCR_ERRORS,BRANCH_ERRORS); the four error channels keep their exact pre-fold merge order and codes (unification is the dedicated error-channel slice, P2.21). - Components are built per run, never cached.
build_components(config, **overrides)constructs framework-fresh defaults each run (injected overrides honoured), so the stale-CRMProcessorfailure mode — a framework switch on a reused orchestrator silently keeping the wrong haircut table, the reason for comparison.py's two-orchestrator workaround — is structurally unrepresentable. - Context-era test surface: sanctioned builder
tests/fixtures/context.py(make_context),PipelineContext(added to the builder-conformance hard lint, fold/registry/context pins intests/unit/test_orchestrator_fold.py+tests/unit/contracts/test_pipeline_context.py; the stage-execution tests intests/unit/test_pipeline.pynow drive the stage adapters through built contexts. engine/hierarchy.py(3,363 LOC) split intoengine/stages/hierarchy/per the mandatory stage anatomy:graph(parent/ultimate-parent/facility-graph resolution + the fourcp_lookup_*seals),ratings(dual best-rating inheritance),facility_undrawn(synthetic undrawn rows incl. the MOF waterfall; the SA-RW preview stays here until its dedicated slice),unify(loans/contingents/facility-undrawn concat + facility metadata),enrich(QRRE propagation, rating attach, short-term override, property coverage, LTV, lending group), with a thinresolver.pykeepingHierarchyResolver(verbatimresolve()recipe + delegating private methods) andstage.pythe fold adapter.engine/hierarchy.pysurvives as a 28-line back-compat shim, so the 23+ test files importingHierarchyResolverfrom it are untouched. Function bodies moved verbatim — the defensive-surface ratchet metrics are byte-identical (fill_null 446, presence guards 374, collect_schema probes 166) and the parity gate stays byte-identical;max_engine_module_locbanks 3,364 → 2,252.engine/classifier.py(2,227 LOC) split intoengine/stages/classify/per the mandatory stage anatomy:attributes(counterparty/SL joins, independent flags, shared SME size-test expr — keeps the_pt_upper/_sa_classscratch-column builders co-located withderive_independent_flags),subtypes(SME/retail/QRRE class mutation, Art. 147(5) reclassification, IRB-class sync, B31 subclass),re_split_flags(the 6-function RE loan-split candidate block +_SECURED_TARGET_*, isolated so the Slice-4 re_split co-location never moves it again),permissions(model-permission resolution, permission exprs, CLS006 diagnostics),approach(decision ladder + B31 Art. 147A restrictions +_B31_SLOTTING_ONLY_SL_TYPES),audit(audit trail, CLS008/DQ008 warnings), with a thinclassifier.pykeepingExposureClassifier(verbatimclassify()recipe — materialise-before-diagnostics, rawseal()after, CCR brand probe — plus_build_bundle) andstage.pythe fold adapter.engine/classifier.pysurvives as a 24-line back-compat shim, so the 30 test files importingExposureClassifierfrom it are untouched; the staleENTITY_TYPE_TO_*re-export comment is deleted (all consumers import fromdata.tables.entity_class_mappingdirectly). Function bodies moved verbatim — ratchet metrics unchanged; the 8@citesdecorators moved with their functions and the citation snapshot/matrix re-keyed to the new module paths.- FX conversion and the RE-split each get their stage package (Slice 4).
engine/stages/fx/lands the FX code seam:converter.py(the statelessFXConverterfive-method kernel +create_fx_converter, moved verbatim fromengine/fx_converter.py) andconversion.py(convert_resolved_frames— the five-converter block extracted verbatim fromHierarchyResolver.resolve, still invoked at the same unify → FX → enrich seam because LTV / property-coverage / lending-group totals and the classifier's GBP thresholds assume reporting-currency amounts). Registry promotion of FX to a standalone stage is deferred — the code seam landed here, the EUR/GBP config-mutation hoist landed in Slice 1;engine/fx_rate_sync.pystays with the pipeline facade.engine/stages/re_split/co-locates the whole RE loan-split:splitter.py(RealEstateSplitter+ the 19 split/allocation helpers moved verbatim fromengine/re_splitter.py, producer seal and RE001 keep-alive included),flagging.py(the candidate-flagging brain moved verbatim fromstages/classify/re_split_flags.py— still invoked fromclassify()at the same point), andstage.py(the Slice-1 fold adapter, previouslystages/re_split.py). Split parameters stay in the data layer (data/tables/re_split_parameters.py, arch_check check 5).engine/fx_converter.pyandengine/re_splitter.pysurvive as thin back-compat shims, so the test files importingFXConverter/RealEstateSplitterfrom them are untouched. Function bodies moved verbatim — no behaviour change (the recorded null-currency FX-haircut findings and the Art. 123B post-FX currency comparison are deliberately left as-is); ratchet metrics unchanged; the 2 moved@citesfunctions re-keyed in the citation snapshot/matrix. - The multi-level direct/facility/counterparty allocator is written once:
engine/kernels/allocation.py(Slice 6). The drifting copies of the "classify item by beneficiary level → aggregate per beneficiary → join at three keys → pro-rata by basis / level total → additive combine" skeleton now parameterise one kernel —allocate_multi_level(annotate direction),expand_items_pro_rata(expand direction), the level-lookup builders (direct_level_lookup/grouped_level_lookup+ancestor_membership_expr/explode_facility_membership— the[parent]-fallback expression previously duplicated 4×), the 3-join +beneficiary_type-switched coalesce (join_items_to_level_lookups/switch_by_beneficiary_level), and the attribute-precedence sibling (level_attribute_lookup/coalesce_attribute_levels). Converted copies, each a thin parameterisation keeping its exact semantics: provisions (crm/provisions.py— pre-CCF synthetic basis, ancestor cascade, null/unknown beneficiary dropped), property coverage (stages/hierarchy/enrich.py— drawn-only Art. 147 basis, immediate-parent.over()window weights viapartition_by_nullable, unknown→direct), guarantees (crm/guarantees.py—ead_after_collateralbasis, expand direction, inner-join stranding and thebeneficiary_type="loan"rewrite preserved), the CRM collateral lookup builders (crm/processor.py— ancestor-subtree, AIRB-pool-aware aggregates stay caller-side), the LTV metadata lookup (add_collateral_ltv— direct→facility→cp coalesce, contingent-exclusion and order-dependentunique(keep="first")tie-break preserved and documented), and the collateral-link demand pooling (crm/link_allocation.py). Every drift axis (basis, cascade vs immediate parent, unknown-type handling, window vs join weight mechanics — with each copy's float associativity tied to its mechanics) is a kernel parameter documented in the module docstring;crm/expressions.beneficiary_level_exprdelegates to the kernel classifier. Documented residue: FCSM (crm/simple_method.py) stays unconverted — it is level-blind (one aggregate joined under three keys, double-count-prone) and converting it through the level-aware kernel would change results for colliding reference namespaces; the collateral-coreancestor_facilitiescolumn materialisation (crm/collateral.py) keeps its 3-way null-list fallback;crm/look_through.pycontains no allocator (row-wise re-anchoring only). Zero behaviour change — full suite green at pre-slice counts; ratchet banksfill_null439→431, presence guards 374→372. - Polars namespace retirement begins: the
ccrnamespace is deleted (Slice 7).engine/ccr/namespace.py— a pure delegation shim over therwa_calc.engine.ccrfree functions with zero accessor call sites in src or tests (the production path has always called the free functions directly viapipeline_adapter) — is removed outright, all 8 delegate methods with it, along with the registration import inengine/ccr/__init__.py. The scaffold contract (tests/contracts/test_ccr_engine_scaffold.py) now pins the package's public free-function surface (__all__+ callability) instead of thelf.ccrregistration. The slotting namespace is converted to plain typed functions.engine/slotting/namespace.py(SlottingLazyFrame+SlottingExpr, 469 LOC) becomesengine/slotting/transforms.py: every method is now a module-level functionfn(lf, config, ...) -> LazyFrame(orfn(expr, *, ...) -> Exprforlookup_rw/lookup_el_rate), bodies moved verbatim, public names unchanged;SlottingCalculator.calculate_branchcomposes them via.pipe(fn, config). The_SHORT_MATURITY_THRESHOLD_YEARSscalar moved with its consumer (arch_check allowlist re-keyed), ~107 test accessor call sites across 5 files rewired to direct function calls,tests/unit/crr/test_slotting_namespace.pyrenamed totest_slotting_transforms.py, and the@cites("CRR Art. 153(5)")key re-homed totransforms::apply_slotting_weightsin the citation snapshot. The sa namespace — the biggest — is converted to plain typed functions across three focused modules.engine/sa/namespace.py(SALazyFrame, 2,153 LOC) splits by cohesion intoengine/sa/risk_weights.py(base RW assignment:apply_risk_weights+ the CRR/B31 override chains, sovereign/ECA/covered-bond helpers,SA_INPUT_CONTRACTand the three_SA_*_RWscalar dicts),engine/sa/rw_adjustments.py(the five post-base modifiers: FCSM, life-insurance mapping, guarantee substitution, Art. 123B currency mismatch — recorded findings moved verbatim — and Art. 110A due diligence, plus the guarantee helpers) andengine/sa/factors_output.py(calculate_rwa,apply_supporting_factors,build_audit); bodies verbatim, public names unchanged, no module near the LOC ratchet.SACalculator.calculate_unified/calculate_branchcompose them via.pipe; the CRM link-ranking preview (crm/processor._annotate_link_rank_metric) now defers-importsrisk_weights.apply_risk_weightsdirectly (the namespace-registration lazy import is gone; the sa↔crm coupling stays lazy in both directions). The deadprepare_columnsmethod (zero call sites anywhere) is deleted with the shim. ~76 test accessor call sites across 11 files rewired; the citation-hygiene path pins (test_crr_art114_citation_paragraph.py,test_crr_art123_payroll_citation.py) re-point to the new module homes, and 20@citeskeys re-home in the citation snapshot. The irb namespace — the last registration — is converted to plain typed functions, and the Polars-namespace pattern is extinct.engine/irb/namespace.py(IRBLazyFrame+IRBExpr, 984 LOC) becomesengine/irb/transforms.py: all 17 LazyFrame methods and 3 Expr methods are module-level functions (bodies verbatim — including the two1.06 if config.is_crr else 1.0scaling-factor reconstructions, deliberately NOT rewritten toconfig.scaling_factoruntil Phase 5 rulepack threading);IRBCalculator._run_irb_chaincomposes them via.pipe; theIRBExpr/IRBLazyFramere-exports leaveengine/irb/__init__; ~370 test accessor call sites across 24 files rewired (tests/unit/crr/test_irb_namespace.pyrenamed totest_irb_transforms.py), and 5@citeskeys re-home in the citation snapshot. With the namespace pattern extinct, the 6 ty rules disabled for it (unresolved-attribute,invalid-argument-type,invalid-assignment,invalid-parameter-default,no-matching-overload,not-subscriptable) are re-enabled and the surfaced backlog burnt down to zero with typing-only fixes:ColumnSpec.dtype/EdgeColumn.dtypewidened to Polars'PolarsDataType(killed ~1,050 of the 1,366 diagnostics at the two declaration sites), the SA/equity when-then chain appenders re-annotatedThen | ChainedThen -> ChainedThen,has_required_columnsupgraded to aTypeGuard[LazyFrame](narrows every guarded optional-frame site),brandmade generic over LazyFrame/DataFrame, the COREP/Pillar-3workbook: objectparams typedxlsxwriter.Workbook, plus localizedMapping/Sequencecovariance fixes andcasts on heterogeneous report-row dicts. Nine per-linety: ignore[unresolved-attribute]comments remain, all one class:config.irb_permissions.<attr>where the field is annotatedIRBPermissions | Nonebut is always derived non-None inCalculationConfig.__post_init__(justification comments in situ). The dev-tool lock bumps ty 0.0.26 → 0.0.49, which resolves polars' decoratedcollect()/collect_all()overloads correctly — eliminating the ~200InProcessQuery | DataFrameunion false positives that were the remaining blocker. All four namespace retirements (ccr → slotting → sa → irb) complete;ty check src/is clean with zero globally disabled rules. Zero behaviour change. - The error channel is unified — stage data-quality errors now reach the result with their ORIGINAL codes (Slice 8, closes P2.21). This CHANGES observable error codes: the lossy rewriting of bundle-attached
CalculationErrors into dynamically-mintedPIPELINE_<STAGE>codes is deleted. Hierarchy (HIE*,DQ004/DQ005), classification (CLS*,DQ008), CRM (CRM*), RE-split (RE*, upstream-dedup preserved) and equity errors now arrive onAggregatedResultBundle.errorsverbatim via the newSTAGE_ERRORSartifact channel (engine/orchestrator.py,append_stage_errors) — code, severity, category, and all six reference fields (exposure_reference/counterparty_reference/regulatory_reference/field_name/expected_value/actual_value) are preserved instead of destroyed (previously: code →PIPELINE_HIERARCHY_RESOLVER/PIPELINE_CLASSIFIER/PIPELINE_CRM_PROCESSOR/PIPELINE_RE_SPLITTER/PIPELINE_EQUITY_CALCULATOR, severity force-upgraded to ERROR, category forced to CALCULATION, every reference field dropped — so downstream consumers now see WARNINGs where they previously saw ERRORs). TheCCR_ERRORSside channel, which existed only to dodge the rewrite, folds intoSTAGE_ERRORS(CCR001/CCR010/CCR011 unchanged on the result); the securitisation allocator keeps its loader-channel append (SEC codes were already verbatim, and moving them would reorder the final list). Stage crashes keepPipelineError→convert_pipeline_error→PIPELINE_<STAGE>(a crash has no original code), as does the IRB-mode missing-model-permissions warning. Final merge order in the facade:result.errors(incl. branch-calculator warnings) + loader/securitisation bundle errors +STAGE_ERRORS+ converted crash errors. Pinned by four new verbatim-survival tests (tests/unit/test_pipeline.py::TestStageErrorChannel— hierarchy/classify/CRM/equity sentinels assert frozen-dataclass equality onresult.errorsand the absence of the correspondingPIPELINE_*code); the crash-channel pins (test_convert_pipeline_error,test_stage_error_returns_error_result, fold tests) pass unchanged. - The Phase-4 shape is now gated: arch_check checks 14-16 land, and the standing instructions flip with them (Slice 9, closes Phase 4). Check 14 bans Polars namespace registrations (
register_(lazyframe|dataframe|expr|series)_namespace) anywhere undersrc/rwa_calc— no allowlist, the pattern stays extinct. Check 15 pinsengine/registry.pyas a literal stage list: module body is the docstring, imports, the module logger, and assignments whose value is a literal tuple ofStageSpec(...)calls with literal/name/attribute arguments (conditionals, loops, comprehensions, function defs all violate). Check 16 pins the stage anatomy: everyStageSpec.fnis<engine/stages/ module>.runresolved from the registry'srwa_calc.engine.stagesimports, stage modules bind a top-levelrun, and every package underengine/stages/exposesrunfrom its__init__unless pinned in the shrink-onlySTAGE_PACKAGES_WITHOUT_RUNset (fx— registry promotion deferred; stale entries are violations). All three are mirrored intests/contracts/test_arch_migration_gates.py; the module docstring's numbered check list now also documents the previously-undocumented check 13. The ~600-LOC engine-module ceiling is deliberately NOT a new failing check: the existingmax_engine_module_locratchet (banked at 1,499, monotone decreasing) is the mechanism, now documented at the ratchet config and in the check-11 docstring as the Phase-4 target the bank must keep falling toward. Per the do-not-do register, the agent-facing instructions flip in the same change: CLAUDE.md's "Polars custom namespaces" design pattern and "Namespace extensions" Polars convention are replaced by the plain-typed-functions +.pipe(fn, config)convention, the Architecture section documents the fold (registry / orchestrator / stages /PipelineContext/RulepackV0), andengine/registry.py+engine/orchestrator.pyjoin the shared-engine-file single-stream lists in CLAUDE.md,/next-itemsand/sonar-clean(theengine-implementercharter gains the new invariants). Stale docs refreshed mechanically:docs/specifications/observability.md(stagestage_timerrecords come fromrwa_calc.engine.orchestrator; run-level records stay onrwa_calc.engine.pipeline),docs/architecture/pipeline-collect-barriers.md(edge inventory re-pointed at the stage adapter modules — the facade fires no edges), anddocs/development/module-dependencies.mdregenerated (194 modules; the retired*.namespacenodes are gone). The ty rule re-enablement was already recorded in the Slice-7 entry above.
Fixed (Phase 4 — recorded regulatory decision)¶
- The hierarchy SA-RW preview leaves the hierarchy package and adopts the shared entity RW expression (slice 5c). The facility-share riskiest-counterparty selection (
engine/stages/hierarchy/facility_undrawn.py::_derive_facility_share_counterparty) now compilesbuild_entity_rw_exprfromdata/tables/guarantor_rw.pyinstead of the package-local_preview_sa_rw_expr(deleted, with its nested_cqs_lookupand seven RW-table imports). The builder keeps the preview's sovereign / institution / corporate+covered-bond / retail / high-risk branches value-identical (corporate stays on the CRR Art. 122 Table 5 dict under both frameworks, preserving preview parity) and closes the branches the old preview lacked: PSE Table 2A (Art. 116(2)), RGLA Table 1B (Art. 115(1)(b)) with the GB→20%/else→100% unrated approximation (sourced from the lookup'scountry_code), international organisations 0% (Art. 118), named MDBs 0% (Art. 117(2)) — these entity types previously fell to the flat conservative 1.0 default, which stays in place for genuinely unmatched types (equity / other items). Non-binding for risk weights, but the preview selects which counterparty receives a multi-counterparty facility's undrawn EAD, so selections involving PSE/RGLA/IO/MDB candidates can flip (PSE/RGLA/IO/MDB candidates now rank lower than before). Pinned at the expression level bytests/unit/test_entity_rw_preview.py(8 hand-derived pins); zero pre-existing tests changed outcome (no fixture pins a multi-CP share with those entity types). - IRB exposures guaranteed by PSEs, RGLAs, international organisations and MDBs now receive the guarantor's preferential SA risk weight (CRR Art. 235 RWSM). The IRB guarantor chain (
engine/irb/guarantee.py::_compute_guarantor_rw_sa) handled CGCB/CCP/institution/corporate guarantors only — PSE, RGLA, IO and MDB classes fell to.otherwise(null), makingis_guarantee_beneficialfalse and silently discarding the guarantee under a misleadingGUARANTEE_NOT_APPLIED_NON_BENEFICIALaudit label (with null risk weights leaking into post-CRM guaranteed-portion reporting). The chain now compiles the new shared guarantor RW expression (data/tables/guarantor_rw.py— branch chain mirrored from the SA-side reference implementation: domestic CGCB 0% → CGCB CQS table → CCP 2%/4% → IO 0% (Art. 118) → named MDB 0% (Art. 117(2)) → MDB Table 2B (Art. 117(1), previously misrouted to institution Table 3) → institution ECRA/SCRA → PSE Table 2A (Art. 116(2)) → RGLA Table 1B (Art. 115(1)(b)) → corporate), deleting the IRB chain's inline CGCB literals. RWA-decreasing for affected guarantees; unrated PSE/RGLA guarantors keep the documented GB→20%/else→100% approximation (recorded decision — no guarantor sovereign CQS join exists). Pinned by 8 acceptance tests (CRR + B31 arms, hand-calculated expectations, verified failing pre-fix) + 4 IO/MDB unit pins; zero pre-existing tests changed outcome; the 10k parity set stays byte-identical (it contains no such guarantors — the P5.11 acceptance hole this partially fills). The SA path (slice 5b) and the hierarchy SA-RW preview (slice 5c, below) have since adopted the shared expression.
Changed (architecture — migration Phase 1: eager stage edges)¶
- Stages now exchange materialised frames; laziness is strictly intra-stage.
engine/materialise.pyis rewritten aroundmaterialise_edge(lf, config, label), called at every stage exit (hierarchy_exit,ccr_exitwhen a derivatives book is present,classifier_exit,crm_exit,re_split_exit, and the three calculator branches). The hand-placed barrier inventory —classifier_output,pipeline_pre_branch,crm_post_ead_unified/_fanout,crm_no_guarantee— is deleted; Two benchmark-justified intra-stage checkpoints survive inside CRM:crm_post_ead(a controlled A/B showed removing it costs 35–52% on the full-pipeline benchmarks — the collateral lookups re-execute the provisions→CCF→EAD chain without it) andcrm_pre_guarantee_unified(empirically irreducible on Polars 1.37). Bundle fields stayLazyFrame-typed (cheap.lazy()wrap) until the Phase 3 producer seal. Byte-identical outputs; the inter-stage plan-depth SIGSEGV class is now unrepresentable. See the rewritten Stage-Edge Materialisation page. - One execution semantics; spill failures are loud. The cpu/streaming dual mode collapses into: in-memory edges by default, opt-in spill-to-parquet via the new
config.spill_edges; a sink failure raisesSpillErrorinstead of the previous silent in-memory fallback (which defeated the only purpose of spill mode).collect_engine="streaming"is deprecated (accept-and-warn, one release). The module-global spill registry andatexithook are replaced by a run-scoped capture whose cleanup lives in the orchestrator'sfinally. - Every run now records a materialisation map. Each edge collect emits an
EdgeEvent(label, rows, columns, estimated bytes, wall ms, spill mode); the map is logged at INFO at run end and written into the audit-cachemanifest.json(materialisation_map). - Plan-node ceilings replace barrier folklore.
tests/integration/test_stage_edges.pyasserts the edge inventory in pipeline order and pins per-edge unoptimised plan-node ceilings (measured 2026-06-11 on Polars 1.37: hierarchy 1,586; CRM ~1,840; everything else ≤100 — ceilings at ~2×, SIGSEGV threshold ~25,000), plus a Polars version pin that forces recalibration on upgrade (RWA_PRINT_EDGE_NODES=1). arch_check gains anengine_eager_collect_sitesratchet metric (47 at baseline) so the small-lookup collect census cannot grow. - Post-aggregation views are computed once, not per accessor.
OutputAggregator.aggregate()now collects its summary views in two dependency-safe batches (pre-floor and post-floor) and exposes them as eager-backedLazyFramewraps, so downstream consumers'.collect()calls are near-free instead of re-executing the concat→multiplier→floor→group_by plan each time;ReconciliationResponsecaches collected frames, and the/api/reconcileendpoint executes each view once per response (previously ~7 plan executions per request). Byte-identical outputs; field types unchanged.
Changed (architecture — migration Phase 3: producer-sealed edge contracts) — IN PROGRESS, BREAKING for direct bundle construction¶
- Edge contracts land in
contracts/edges.py.EdgeColumn/EdgeContractdeclare per-edge column contracts (dtype, required/optional, producer-owned default, Boolean-only null fill, null-semantics annotation, regulatory citation);conform()raisesEdgeContractViolationon missing required columns or dtype drift (programming-error channel), injects defaults for absent optional columns, strips undeclared scratch and emits canonical column order.seal()= conform + brand; the brand is deliberately lost on any frame transformation, so only the exact object that went through the seal carries it. The Boolean-only fill conservatism gate is enforced when a contract is declared (a Float/Stringfill_null_defaultis aValueError). - The loader is a producer-enforced boundary. Every input table is sealed at load against
RAW_TABLE_EDGES(one edge perRawDataBundleframe field, seeded from theColumnSpecschemas): missing required columns now produce DQ001 errors plus typed-null injection — implementing theColumnSpec.requiredcontract that was previously documentary only; dtype mismatches cast (strict=False); undeclared input columns are stripped; tables arrive schema-complete, canonically ordered and branded. Input alias translation happens in the loader exactly once (node_type→child_typeon facility mappings). RawDataBundledemands sealed frames. All 18 frame fields are registered incontracts/bundles.SEALED_FRAME_FIELDS;__post_init__raises unless each non-None frame carries its loader-edge brand. Tests construct bundles via the contract-derived buildertests/fixtures/raw_bundle.py(make_raw_bundle/seal_raw_table) — same keyword surface asRawDataBundle, frames sealed exactly as the loader seals them, so test bundles are shape-identical to production-loaded ones (~95 test files migrated).- Schema gaps surfaced by the strip are now declared contract:
LOAN_SCHEMAgainsltv,property_type(null defaults — never 0.0/""),has_income_cover(Boolean False per CRR Art. 126(2), mirrored onCONTINGENTS_SCHEMA),ava_amountandother_own_funds_reductions(CRR Art. 159 Pool B (c)/(d), null when unreported).COLLATERAL_SCHEMA.is_main_indexloses itsFalsedefault: null means "index membership unreported" and the haircut engine resolves null → main-index (CRR Art. 224 Table 4) — a load-time False fill silently re-rated unreported equity to the higher other-listed haircut. engine/hierarchy.pysheds its column-presence guards. With everyRawDataBundleframe sealed at the loader edge, the resolver'sif "X" in <table>_colsbranches on declared input columns were dead: the loans/contingents coercion blocks, facility-undrawn select, MOF expansion, facility-share derivation, QRRE propagation, property-coverage/LTV collateral joins, and short-term-rating lookup now read sealed columns directly (engine ratchet: −57 presence guards, −15collect_schemaprobes, −20fill_nullsites). Dead Boolean fills whose value equals the schema default (already filled at load) are removed; all Float/String fills and all null-VALUE semantics (nullchild_type, nullis_qualifying_re) are preserved._normalise_facility_mappingsis deleted outright — the loader translatesnode_type→child_typeexactly once and sealed tables always carrychild_type. Unit tests that call hierarchy private helpers directly now seal their hand-rolled frames viatests/fixtures/raw_bundle.seal_raw_table, mirroring production input shape.engine/classifier.pysheds its column-presence guards; CLS005 / CLS007 retired. With the exposures frame sealed againsthierarchy_exit, the fourCounterpartyLookupframes sealed against thecp_lookup_*edges, andmodel_permissionssealed at the loader edge, the classifier's presence machinery was dead: the 15-branch conditional cp_ attribute select collapses to one unconditional select, the model-permissionscountry_codes/excluded_book_codes/ppu_reasoninjections and themodel_idearly-return go, the RE-split_re_split_null_defaultsearly-return and capped-column fallback are deleted, and the QRRE /is_defaulted/beel/internal_pd/has_income_cover/cp_is_managed_as_retailpresence gates collapse to direct reads (engine ratchet: −49 presence guards, −3fill_nullsites, −3collect_schemaprobes; classifier.py −202 LOC). The CLS005 ("is_managed_as_retail column missing") and CLS007* ("is_financial_sector_entity column missing") warnings are deleted — the absent-column states they detected are unrepresentable on sealed input (the seal injects declared-but-absent columns as typed nulls); their null-VALUE semantics (fill_null(True)pool-management default per Art. 123A,fill_null(False)FSE gate per Art. 147A(1)(e)) are preserved verbatim, as are all optional-tableis Nonegates (specialised lending, model permissions) and the CLS006/CLS008 null-VALUE diagnostics. Sealed-invariant tests replace the absence-warning tests (tests/unit/classifier/test_p1_125_fse_column_warning.py,tests/unit/test_art123a_retail_criteria.py), mirroring the CLS004 pattern.- Every pipeline stage edge now carries a producer seal. The full chain — loader → hierarchy (
hierarchy_resolved/hierarchy_exit/ccr_exit) → CounterpartyLookup (fourcp_lookup_*contracts) → classifier (classifier_exit/_ccr, brand-selected by input) → CRM (crm_exit/_ccr) → RE-split (re_split_exit/_ccr) → calculator branches (sa_branch/irb_branch/slotting_branch, conformed before the shared collect and branded as DataFrames) → aggregator (aggregator_exit, 297 columns — the reporting input contract). Bundle__post_init__validates brands via theSEALED_FRAME_FIELDSregistry (tuples for multi-producer fields); transformed frames lose their brand by design. - Conditional columns (
EdgeColumn(inject=False)). Path-dependent columns (guarantee substitution, provisions, FCSM, SA-CCR provenance, regime-gated floor/currency columns) are declared-if-present: dtype-validated and never stripped when the producing sub-step ran, never injected when absent. The parity gate forced this design — blanket null-injection made the presence-gated SA/IRB guarantee-substitution machinery execute on null data and moved IRB risk weights. Each group flips to injection alongside its consumer's guard deletion with verified null-path equivalence. - Wave-2 guarantor rework — the path-dependent CRM columns now inject as typed nulls. 18 of the 19 conditional
crm_exitcolumns (the provision splitprovision_on_drawn/provision_on_nominal, the guarantee metadata and guarantor-attribute set, and the FCSM pairfcsm_collateral_value/fcsm_collateral_rw) flip to injection on thecrm_exit, calculator-branch andaggregator_exitcontracts, so per-row outputs carry a uniform shape whether or not the producing CRM sub-step ran (all-null = sub-step skipped). Consumers verified null-path-equivalent: on the 10k parity set the per-row frames gain exactly the 18 all-null columns with zero value drift in every shared column.guarantor_entity_typestays conditional as the run-level presence sentinel that keeps the SA/IRB guarantee-substitution machinery — and its derived audit columns (guarantor_rw,guarantee_status,pre_crm_risk_weight, …) — off unguaranteed runs (lazy column addition is row-independent, so a value gate cannot prevent the shape divergence). The_inst_guarantor_short_termscratch column is dropped at source in the SA guarantee substitution and removed from the branch/aggregator contracts; the FCSM presence early-exit and two always-ensured seniority/FSE presence branches in the IRB EL adjustment are deleted (presence-guard ratchet 377→374,collect_schemaprobes 168→166). - Guard retirement banked by the ratchet: presence guards 549→377 (−172),
fill_nullsites 469→446,collect_schemaprobes 191→166;hierarchy.py−413 LOC,classifier.py−202 LOC. Dead absent-column warning families (CLS005, CLS007, QRRE CLS004) deleted with their unrepresentable-state tests, replaced by sealed-frame invariant pins. - Unknown-flag conservatism fixes (recorded decisions):
qualifies_as_retailunknown → non-qualifying 100% (CRR Art. 123 — the 75% weight is preferential);has_default_definition_infounknown → the Art. 155(3) 1.5× scaling applies;is_main_indexunknown → preserve the engine's null→main-index resolution (Art. 224 Table 4). Zero goldens changed; pipeline paths unaffected (the classifier emits non-null in-pipeline). - Contract-derived test builders are the sanctioned construction path at every grain:
make_raw_bundle/seal_raw_table,make_resolved_bundle/make_counterparty_lookup/make_classified_bundle/make_crm_bundle/make_aggregated_bundle, plustests/fixtures/contract_columns.pypads for hand-rolled branch frames. ~150 test files migrated across the phase; recurring lesson: tests that omitted columns production always carries now receive typed nulls and must supply production-realistic values — never changed expected numbers. - Producer-contract robustness fixes: the CRM001 unusable-collateral skip path now emits the full CRM contract (runs the collateral step on an empty schema-valid table); an equity-stage failure no longer nukes non-equity results via a self-inflicted aggregator seal violation (
equity_typeoptional-with-injection).
Changed (architecture — migration Phase 2: dead-path deletion and protocol diet) — BREAKING for direct API consumers¶
- The legacy dual CRM orchestration is deleted.
CRMProcessor.apply_crm()/get_crm_adjusted_bundle()(and theircrm_post_audit_fanoutedge) are gone;get_crm_unified_bundle()is the single CRM entry point. The misdirected-AIRB collateral diagnostic (CRM006, CRR Art. 181 / B3.1 Art. 169A) — which only the dead path ever emitted — is migrated into the unified path, so production now surfaces it for the first time. - One branch entry point per calculator.
SACalculator.calculate/get_sa_result_bundle,IRBCalculator.calculate/get_irb_result_bundle/calculate_expected_loss,SlottingCalculator.get_slotting_result_bundle, andEquityCalculator.calculateare deleted — none were invoked by the orchestrator. Each calculator keepscalculate_branch()(plus SA'scalculate_unified()for the Basel 3.1 output floor); equity keepsget_equity_result_bundle(). - Branch-path error accumulation is restored.
calculate_branch()(and SAcalculate_unified()) now take an optionalerrors=accumulator wired by the orchestrator, so SA004 (Art. 110A due diligence), SA005 (equity in main table, Art. 133), SF001 (Art. 501 SME group aggregation) and EL diagnostics reachAggregatedResultBundle.errorswith their original codes — previously these production warnings were silently discarded (only the dead bundle paths collected them). Pinned per calculator bytests/unit/test_branch_error_accumulation.pyand end-to-end bytests/integration/test_branch_error_accumulation.py. - Orphaned contracts deleted.
LazyFrameResult,SAResultBundle/IRBResultBundle/SlottingResultBundle,SACalculationError, the approach-split fields (sa_exposures/irb_exposures/slotting_exposures) and the always-Nonecrm_auditfield onClassifiedExposuresBundle/CRMAdjustedBundle(consumers filter the unified frame onapproach; the CRM audit projection ships via the audit cache), the never-calledvalidate_classified_bundle/validate_crm_adjusted_bundle, and the zero-implementationCCRCalculator/SchemaValidatorProtocol/DataQualityCheckerProtocolprotocols. - Protocol conformance is asserted on real implementations.
tests/contracts/test_protocols.pynow checks every pipeline component class (17 protocol/implementation pairs) via runtimeisinstance+ typed assignment, replacing the stub-based tests that could pass while the real component drifted. - Empty-LazyFrame sentinels are gone from the engine; optional frames are
None.RawDataBundle.lending_mappingsis optional (loader returnsNonewhen the file is absent; the hierarchy resolver treatsNoneas group-of-one per CRR Art. 4(1)(39), identical to an empty table), andCollateralLinkAllocation.collateralstaysNonefor absent collateral. New arch_check check 13 (+ contracts mirror) bans barepl.LazyFrame()construction inengine/**. - Parity gate:
scripts/parity_gate.pycaptures/compares the fullAggregatedResultBundleover the deterministic 10k stress set (4 framework/permission configs). Result vs the pre-Phase-2 baseline: every per-exposure frame byte-identical; group-by sum aggregates equal to float-reassociation tolerance (Polars parallel Float64 summation is not deterministic across processes — verified on identical code); error growth = exactly one SA004 warning per Basel 3.1 run (the restored due-diligence diagnostic).
Fixed¶
- Financial Collateral Simple Method (CRR Art. 222) election was silently ignored on the production pipeline path.
compute_fcsm_columns/undo_sa_ead_reductionwere only invoked by the legacyget_crm_adjusted_bundleentry point; the orchestrator'sget_crm_unified_bundlenever ran them, so a firm electingcrm_collateral_method=SIMPLEgot Comprehensive Method treatment instead — EAD reduced by financial collateral with no Art. 222 risk-weight substitution. The FCSM steps are now ported into the unified path behind the same election guard (mirroring the legacy ordering). SIMPLE-electing runs will show changed SA RWA: EAD is no longer reduced by financial collateral, and the secured portion takes the collateral risk weight (20% floor / carve-outs per Art. 222). COMPREHENSIVE (default) runs are byte-identical. Recorded as a FIX decision indocs/plans/target-architecture-migration.md§6. Pinned bytests/acceptance/crr/test_art_222_fcsm_unified_pipeline.py(6 orchestrator-path tests, full + partial coverage). - COREP and Pillar 3 no longer disagree about on/off-balance-sheet filtering when the indicator columns are missing. The copy-pasted
_filter_on_bshelpers had drifted: COREP returned an empty frame when neitherbs_typenorexposure_typeexists, Pillar 3 returned all rows — double-counting the full population across on-BS and off-BS cells. Both generators now sharereporting/kernel/(column resolution, approach/BS filters, safe sums, null rows), unified on return empty (a missing balance-sheet indicator must not silently pass rows through; decision recorded indocs/plans/target-architecture-migration.md§6). Pipeline output always carriesexposure_type, so this only affects synthetic/minimal inputs. Genuinely divergent semantics (safe_sum0.0 vs None,col_sumempty-frame handling, approach-column candidates) are preserved per-caller via explicit parameters. Pinned bytests/unit/reporting/kernel/test_kernel.py(23 tests). - Master CI is green again. The
Lint & Formatjob was failing on 22 ruff errors (F401/I001/SIM102) plus 8 maskedruff formatfailures acrosstests/{fixtures,acceptance}/ccr/introduced by recent CCR batches; all fixed (the intentional re-export module now uses explicitX as Xre-export syntax).
Added¶
- Target architecture & migration plan committed to the repo.
docs/plans/target-architecture-migration.mddistils the 2026-06-11 multi-agent architecture review (49 evidenced findings) into the rulepack target architecture and a phased (0–8) strangler migration, with quick wins, a binding do-not-do register, and a decision log. The two investigation plans previously held only in agent session memory are committed alongside:engine-defensiveness-boundary-hardening.md(folded into Phase 3; preserves the ~130/189 KEEP-guard triage) andsingle-lazy-plan-refactor.md(SUPERSEDED by Phase 1; preserves the Polars 1.37 plan-depth SIGSEGV evidence).IMPLEMENTATION_PLAN.mdcross-links the phases. - Architecture-debt ratchet and import-direction gates (arch_check checks 11–12). Check 11 measures the engine defensive surface (
.fill_null(sites, string-literal column-presence guards,.collect_schema(probes, max engine module LOC) against a committed baseline (scripts/arch_metrics.json) and fails any increase; the watchfire@citescount may never decrease. Improvements are banked viapython scripts/arch_check.py --update-baseline. Check 12 enforces downward-only imports (contracts ↛ api/ui/reporting/engine/analysis; engine ↛ api/ui/reporting/analysis; reporting ↛ api/ui; data/domain ↛ anything above), with known legacy inversions allowlisted against the migration phase that retires them. Mirrored as contract tests intests/contracts/test_arch_migration_gates.py. - A real pre-commit gate.
.pre-commit-config.yaml(local hooks:arch_check,ruff check,ruff format --checkoversrc/tests/scripts) so non-agent commits are gated the same way as agent commits. - Dedicated CI benchmarks job. Benchmark bodies are excluded from the default dev loop and the CI tests job (27 test bodies, ~112s of dead worker time per run, executed despite
--benchmark-disable); a new additive CI job runstests/benchmarksand uploadsbenchmark-results.jsonas the stored baseline artifact.scale_10k/scale_100kmarkers registered;--strict-markersenforced. - Citation coverage snapshot replaces the hand-maintained whitelist.
tests/contracts/test_watchfire_coverage.py's 84-row manual WHITELIST is replaced by a generated, committed snapshot (tests/contracts/data/citation_snapshot.json, 131 functions — the whitelist had silently missed 47) diffed bidirectionally against the live@citesstate; regenerate viauv run python scripts/generate_citation_matrix.pywhen a change is intentional.
Changed¶
ExportResultmoved fromrwa_calc.api.exporttorwa_calc.contracts.results(re-exported from the old location for backwards compatibility). This clears the contracts→api and reporting→api layering inversions now enforced by arch_check check 12.- Audit-cache writer relocated to the observability layer.
sink_audit/prune_audit_cachemoved verbatim fromengine/materialise.pytorwa_calc.observability.audit_cache(the "sink_parquet only in materialise.py" invariant they were co-located for was never implemented in arch_check — a verified phantom rule). Import sites updated; behaviour identical. - Stale load-bearing prose corrected.
engine/materialise.py's ">500-node optimizer segfault" comment now states the verified mechanism (recursive plan-tree depth, ≈25,000-node measured threshold on Polars 1.37, barriers also bound plan-construction time);docs/architecture/pipeline-collect-barriers.mdrefreshed (cpu — not streaming — is the default collect engine; all barrier line references re-verified; the undocumentedclassifier_outputbarrier added). - Verified-dead code deleted:
tests/bdd/(empty scaffold),config/fx_rates.py,engine/utils.is_valid_optional_data, and thecontracts/validation.pyduplicate risk-type validators (canonical source:data/schemas.pyVALID_RISK_TYPES_INPUTviaCOLUMN_VALUE_CONSTRAINTS).
[0.2.26] - 2026-06-10¶
Added¶
- Reconciliation now gives comfort on the asset-class allocation, and can reconcile each class portion line-by-line. A new
class_allocationview totals EAD/RWA by risk class on each side — using the raw (un-collapsed) results so a split exposure's portions each count in their own class — and full-joins on the canonical class to showour_*/legacy_*/delta_*per class; a class our engine allocates differently to the legacy one then stands out as offsetting deltas. It surfaces as a new tier-2 table + grouped-bar chart on/reconciliation, aClass Allocationsheet/CSV in the export, andReconciliationResponse.collect_class_allocation()/ReconciliationBundle.class_allocation. Separately, the join key can now carry the risk class: putting the class in bothour_keys/legacy_keysreconciles at the(exposure × class)grain, with the class key normalised andvalue_map-translated on the way into the join (legacyRRE↔ ourresidential_mortgage) so a portion in a class on only one side shows asmissing_left/missing_right— the precise "this exposure moved to a different risk class" signal. The default mapping TOML now mapsexposure_classand documents the recipe. Purely additive analysis — no change to any RWA calculation. Pinned by new cases intests/unit/engine/test_reconciliation.py(TestClassAllocation,TestExposureClassGrain),tests/unit/ui/test_views_reconciliation.py,tests/integration/test_ui_reconciliation.py, andtests/acceptance/reconciliation/test_reconcile_end_to_end.py. - P8.53 — the SA-CCR wrong-way-risk gate (
apply_wwr_gate) is now wired into the pipeline orchestrator.apply_wwr_gatewas implemented and unit-tested (P8.27, 13 tests intests/unit/ccr/test_wwr.py) but never invoked bypipeline.py::_run_ccr_stage, so no end-to-end SA-CCR run routed through the Art. 291(4)-(5) treatment: any trade flaggedis_specific_wwr=Truewas treated as a non-WWR trade. The CCR stage now runsapply_wwr_gate(apply_legal_enforceability_gate(data.ccr)), so a specific-WWR trade is broken out into its own<ns>__wwr__<trade>synthetic single-trade netting set taggedwwr_lgd_override = 1.0per PRA Rulebook CCR (CRR) Part / CRR Art. 291(5)(c), while the non-WWR trade stays in the parent netting set; the override is surfaced onto the synthetic CCR exposure row (pipeline_adapter.py) for audit/COREP reconciliation. The gate'sCCR010(specific-WWR) /CCR011(general-WWR) diagnostics now reachresult.errorsas rawCalculationErrors through a new CCR-error channel — which also surfaces the legal-enforceability gate'sCalculationErrors to the result for the first time. Pinned bytests/acceptance/ccr/test_ccr_wwr1_orchestrator_gate.py(scenario CCR-WWR-1: one specific-WWR + one normal trade throughPipelineOrchestrator). Scope note: this lands the partition + override tag; the downstream IRB LGD = 100% consumption of the override is deferred (blocked on P8.31 CCR→IRB routing — CCR rows route through SA today, where Art. 291(5)(d) "unsecured transaction" treatment already holds). No EAD/RWA arithmetic changed; no new regulatory scalar (CCR_WWR_SPECIFIC_LGD_OVERRIDE = 1.0already indata/tables/sa_ccr_factors.py). Ref: PRA Rulebook CCR (CRR) Part Art. 291(5)(c); CRR Art. 291(5)(c). - P8.39 — QCCP central-counterparty trade-exposure risk weights (2% / 4%) are now correct end-to-end through the orchestrator. The SA calculator already pinned a
ccpentity_type to the Art. 306(1) weights, but two bugs survived: (a) the trade-levelis_client_clearedflag never reached the synthetic CCR exposure row, so a client-cleared QCCP trade was risk-weighted at the proprietary 2% instead of 4% per PRA Rulebook CCR (CRR) Part / CRR Art. 306(1)(c); and (b) the pin keyed on entity_type alone, with no qualifying-CCP gate, so a non-QCCP CCP was wrongly pinned at 2% instead of being treated as an ordinary institution. The fix threads the client-clearing flag (collapsed to netting-set grain viaany()) onto the CCR row ascp_is_ccp_client_cleared(pipeline_adapter.py) and surfaces the QCCP flag ascp_is_qccp(classifier.py, newcp_is_qccpcolumn inschemas.py); the SA QCCP branch (engine/sa/namespace.py) now gates oncp_is_qccp(an absent flag is treated as qualifying, preserving legacyccprows), and a demoted non-QCCP CCP lifts itscp_institution_cqsintocqsso it resolves to the Art. 120 Table 3 institution ladder (e.g. CQS 2 → 50%) per Art. 107(2)(a) rather than the unrated-100% fallback. Net effect: proprietary QCCP → 2% (Art. 306(1)(a)), client-cleared QCCP → 4% (Art. 306(1)(c)), non-QCCP CCP → institution ladder. EAD is unchanged (the SA-CCR Art. 274 EAD is invariant to the risk-weight branch). Pinned bytests/acceptance/ccr/test_ccr_ccp_orchestrator_pin.py(scenarios CCR-CCP-1 proprietary / CCR-CCP-2 client-cleared + a 2-counterparty keyed-join fan-out guard; 12 tests). Ref: PRA Rulebook CCR (CRR) Part Art. 306(1)(a)/(c), Art. 107(2)(a); CRR Art. 306(1), Art. 120, Art. 272 Def (88). - P8.28 — SA-CCR supervisory-alpha carve-out (α = 1.0) for non-financial and pension-scheme counterparties is now applied per netting set. Previously the engine applied the default α = 1.4 uniformly to every netting set, so a derivative with a non-financial counterparty (EMIR Art. 2(9)), a pension scheme arrangement (EMIR Art. 2(10)) or a pension-scheme default-fund-contribution position had its EAD over-stated by a factor of 1.4 (
EAD = α·(RC + PFE)) — the correct value is ~28.6% lower per CRR Art. 274(2) second sub-paragraph. A newcounterparty_typecolumn on the counterparty schema (default"financial"→ α = 1.4;non_financial/pension_scheme/pension_default_comp→ α = 1.0) is joined onto the netting-set frame and reduced to a per-netting-setalpha_appliedscalar (surfaced on the synthetic CCR exposure row for COREP/audit reconciliationEAD = alpha_applied · (RC + PFE));compute_pfe/compute_eadhonour the per-row value when present and fall back to the scalar α otherwise, so every existing CCR scenario (which never sets the column) is unchanged at α = 1.4. The α scalarsSA_CCR_ALPHA = 1.4/SA_CCR_ALPHA_CARVE_OUT = 1.0live indata/tables/sa_ccr_factors.py. Pinned bytests/acceptance/ccr/test_ccr_alpha_carveout.py(scenarios CCR-ALPHA-1 non-financial / CCR-ALPHA-2 pension-scheme / CCR-ALPHA-3 financial control + a 2-counterparty keyed-join fan-out guard; 14 tests). Ref: PRA PS1/26 / CRR Art. 274(2); EMIR Art. 2(9)-(10); BCBS CRE52.1. - P8.23 — long-settlement transactions confirmed to take standard SA-CCR treatment (no special margin-period-of-risk floor); regression-pinned. A prior plan item assumed CRR Art. 271 mandated a "bespoke MPOR floor" for long-settlement transactions. Primary-source verification (CRR Art. 271, 272(2)/(9), 285) confirms otherwise: Art. 271 merely permits long-settlement transactions to be calculated under the SA-CCR chapter, and neither Art. 272 nor Art. 285 prescribes any long-settlement-specific maturity-factor or MPOR treatment — the Art. 285 floors (5/10/20 business days) key off the netting set's margining type. Under SA-CCR a long-settlement trade is therefore an ordinary trade whose maturity factor follows Art. 279c, so the
is_long_settlementflag has no effect on EAD/RWA. No calculation change was made; the behaviour is pinned bytests/acceptance/ccr/test_ccr_ls_long_settlement_inert.py(a long-settlement trade routes through SA-CCR and yields EAD/RWA identical to an economically-identical control — guarding against any future spuriousis_long_settlementbranch). Ref: CRR Art. 271, Art. 272(2)/(9), Art. 285. - P8.29 — Basel 3.1 transitional SA-CCR alpha add-on (PRA PS1/26 Art. 274(2A)–(2B)) is now applied per netting set. For derivative trades entered into before 1 January 2027 with a CVA-exempt non-financial / pension-scheme counterparty (CVA Risk Part 7.1(1)(a)/(b)), Art. 274(2A) phases an "alpha add-on" —
EAD(α=1.4) − EAD(α=1) = 0.4·(RC+PFE)— back onto the netting-set exposure value at 60% (2027) → 40% (2028) → 20% (2029) → 0% (2030+), so the EAD steps down from ~1.24× to 1.00× of the α=1 carve-out value over the transition. A new firm-suppliedis_legacy_cva_exemptflag on the trade schema gates the add-on (collapsed to netting-set grain viaany()); it fires only under the Basel 3.1 framework at a 2027–2029 reporting date and only where the counterparty already qualifies for the α=1 carve-out (alpha_applied == 1.0) — so an α=1.4 financial counterparty receives nothing, and every CRR run / 2030+ date / non-legacy trade is byte-identical to before. The phased uplift is folded into the SA-CCR EAD and surfaced as atransitional_add_onaudit column. The phase schedule lives indata/tables/sa_ccr_factors.py(SA_CCR_TRANSITIONAL_ADDON_PHASE). Art. 274(2B) (exclude the add-on from the leverage-ratio EAD) is not yet applicable — the engine exposes no leverage-ratio EAD path. Pinned bytests/acceptance/ccr/test_ccr_alpha_addon_transitional.py(the four-year phasing plus non-legacy / legacy-financial / CRR-framework controls and a 2-netting-set fan-out guard; 20 tests). Builds on the P8.28 carve-out. Ref: PRA PS1/26 Art. 274(2A)–(2B); CRR Art. 274(2). - P8.31 — SA-CCR derivative exposures now route through IRB (F-IRB / A-IRB) for IRB-permissioned counterparties, instead of always falling back to the Standardised Approach. A synthetic CCR exposure row reaches the classifier with
model_id = null(the rating-inheritance attach that renamesinternal_model_id → model_idonly runs over hierarchy-resolved lending rows, before the CCR rows are appended), so even a counterparty holding an IRB model permission had its derivative counterparty-default-risk RWA computed on the SA ladder. The classifier now surfaces the counterparty's resolvedinternal_model_idascp_internal_model_idand coalesces it intomodel_idat the start of model-permission resolution, so an IRB-permissioned counterparty's CCR derivative exposure resolves its permission and routes through the existing PD/LGD/M machinery — using the SA-CCR EAD (α·(RC+PFE)) as the IRB EAD input (replacing the drawn-amount/CCF flow), per CRR Art. 153(1) (corporate IRB risk-weight formula). The effective maturityMfor the single-trade netting set follows CRR Art. 162(2)(b) (derivatives under a master netting agreement: residual maturity, 1-year floor / 5-year cap) via the existingmaturity_date-clipped derivation — note this corrects the plan bullet's citation, which wrongly referenced Art. 162(2)(g)-(i) (those are the IMM / CVA-internal-model maturity paths, out of scope for an SA-CCR firm). The coalesce is a strict no-op for lending rows whosemodel_idis already populated (verified across 1,600+ tests; CCR-A1 still routes through SA, the QCCP 2%/4% and α-carve-out pins are intact). Pinned bytests/acceptance/ccr/test_ccr_irb1_routing.py(scenario CCR-IRB-1: a corporate F-IRB counterparty with one 5y GBP IR swap → SA-CCR EAD →approach_applied = "foundation_irb",ead_final == ead_ccr, F-IRB risk weight ≈ 145.9%; 6 tests) with goldentests/expected_outputs/ccr/CCR-IRB-1.json. Scope note: this lands the SA-CCR-EAD→IRB routing; the downstream WWR LGD = 100% consumption (P8.53 Change 2b) and the CCR-specific 0.05% PD floor (P8.32) remain deferred and are explicitly not asserted here. Ref: CRR Art. 153(1), Art. 162(2)(b), Art. 161(1)(a), Art. 163. - The reconciliation page (
/reconciliation) now remembers your last completed run and re-opens pre-filled, so you no longer re-type every input each time. After a reconciliation succeeds, all six form fields — data path, framework, permission mode, data format, reporting date, and the (comment-preserving) legacy-mapping TOML — are saved verbatim as JSON to a per-user state file, and the next visit to the form silently restores them (the three dropdowns included). New modulesrc/rwa_calc/ui/app/recon_state.pyholds a frozenReconciliationFormStateplussave_last_run/load_last_run; the state file is~/.rwa_calc/reconciliation_last_run.json, overridable via theRWA_STATE_DIRenv var (the test seam and the packaged-app override). Saving never raises (a save failure just means the next form isn't pre-filled) and a missing/corrupt/partial file falls back to the built-in defaults, so a fresh install behaves exactly as before. Field precedence is explicit-override (a failed submit re-renders with what you chose) > last run > default; the failure path now also preserves the submitted framework/mode/format, which it previously dropped. The saved values are only editable defaults — a stale data path or legacy file still surfaces the usual clear error on the next run. A "Reset to defaults" button (a ghost-styled link shown beside "Run reconciliation" only when a saved run exists) clears the saved state viaGET /reconciliation/resetand returns the form to its built-in defaults. UI convenience only — no calculation impact. Pinned bytests/unit/ui/test_recon_state.pyandtests/integration/test_ui_reconciliation.py(test_reconciliation_prefills_from_last_run,test_reset_restores_defaults_and_clears_saved_run).
Changed¶
- Reconciliation: the legacy side is now aggregated to the key grain instead of silently keeping the first row (REC002). When a legacy exposure spans several lines — a collateralised portion in one risk class, the residual in another, or guaranteed/unguaranteed portions — the engine previously kept only the first row of each key and dropped the rest, understating the legacy EAD/RWA totals and producing false breaks. The legacy side is now collapsed symmetrically with our side: additive components (EAD, RWA, expected loss) are summed and the risk-weight ratio is recomputed from the summed numerator/denominator, so the totals tie out. The
REC002warning is reworded to reflect aggregation (not row-dropping), andREC004now also fires when a legacy key's rows disagree on class/approach (an exposure split across classes), surfacing exactly the case that motivated the per-(exposure × class)grain. The change is inengine/reconciliation.py(_prepare_legacy_side+_aggregate_legacy_to_key_grain); no other RWA calculation is affected. - The landing-page polar-bear constellation now plays a one-shot "tows the text in" intro on phones instead of running off-screen. On the 16:9
sliceSVG the bear's rest anchors (CX_STAND=128,CX_QUAD=114) sat outside the narrow, height-cropped visible band a portrait phone leaves (≈ viewBox x[59,101]), so the bear stood off-screen and you only ever caught it mid-run; and because the bear is vertically centred it would otherwise sit right behind the full-width hero copy.bear-constellation.jsnow parametrises the lifecycle on an anchors object and, at/below the existing880pxmobile breakpoint, plays a single intro — the bear stands centred (always fully visible), then bolts off the right edge — and then hides, leaving only the twinkling starfield. In the same beat the hero copy is towed in from the left by a pure-CSS auto-play animation (@keyframes bear-tow-inon.hero-bodyinhomepage.css), whoseanimation-fill-mode: bothhides the text from the first painted frame (no flash, no JS arming — text still shows normally without JS) and whose timing is kept in lockstep with the script via theINTRO_T0/INTRO_ENDcontract.prefers-reduced-motionshows the copy immediately with no bear. The intro is framed by centring each pose's visual extent (and scaling the bear to the measured visible band) so the head no longer clips as it drops to all fours, and it is driven by anIntersectionObserverso the run-off plays the moment the hero is genuinely on-screen — not lost in the initial mobile paint — and replays whenever the hero re-enters view. As the bear bolts off, the whole hero gives a subtle footfall-synced "ground shake" (a GPU transform on.landing-hero, ramped by the gallop and faded out as the bear leaves frame; the landing body is darkened to--oah-slate-900so the shake never reveals an edge), also off underprefers-reduced-motion. Desktop is unchanged — width > 880px keeps the existing endless STAND→CROUCH→RUN→WALK-IN→RISE walk in the right third. Edits are confined to the two drift-guarded asset pairs (bear-constellation.js,homepage.cssundersrc/rwa_calc/ui/app/static/anddocs/assets/), kept byte-identical bytests/unit/ui/test_tokens_drift.py. UI presentation only — no calculation impact.
[0.2.25] - 2026-06-07¶
Added¶
- Parallel-run reconciliation is now a first-class page in the app (
/reconciliation) and a REST endpoint, replacing the Marimo workbook. The reconciliation feature (run this calculator alongside a firm's legacy engine and compare component-by-component, triaging each break to a data vs. engine fix) is now part of the server-rendered FastAPI/Jinja app: a form takes the data path and an editable TOML mapping, and the result renders across the four drill-down tiers — headline tie-out + per-component summary (with two inline-SVG charts: legacy-vs-ours per component and Σ|Δ| by component), the by-bucket / by-class / by-approach segmentation, the break worklist ranked by materiality, and a per-key forensic table whose bucket filter (?bucket=…) re-reads a cached result without recomputing. The wide forensic frame is projected to a readable column set on screen; the full per-key detail (explain + input drivers) is available via CSV/Excel download. A matching library-first HTTP contract is exposed:POST /api/reconcile(returns arecon_id+ each tier as{columns, rows}) andGET /api/reconcile/export/{csv|excel}. New modulesrc/rwa_calc/ui/views/reconciliation.py(framework-agnostic view helpers) sits over the unchangedCreditRiskCalc.reconcile()API — no calculation impact. Pinned bytests/unit/ui/test_views_reconciliation.py,tests/integration/test_ui_reconciliation.py, and new reconcile cases intests/integration/test_rest_api.py.
Changed¶
- The reconciliation UI moved from a Marimo workbook to the native app;
src/rwa_calc/ui/marimo/reconciliation_app.pyis removed. The native/reconciliationpage (above) is now the single standard surface, so the standalone Marimo reconciliation app — and its stale references to amarimo/server.pythat no longer exists — are deleted. The reconciliation guide (docs/reconciliation/index.md) and the Interactive UI guide are updated to describe the native page and the new REST endpoints. The editable Marimo workbench (/workbench) is unaffected. Docs/UI only — no calculation impact. httpxis now in the default-synced[dependency-groups].devso the REST/UI test gate runs on a clean checkout (tooling fix). The FastAPITestClient(used bytest_rest_api.py,test_ui_app.pyand the newtest_ui_reconciliation.py) importshttpx, but it was declared only in the[project.optional-dependencies].devextra — whichuv syncdoes not install by default — so a plainuv syncleft those integration tests erroring at import. Addinghttpx>=0.27.0to[dependency-groups].devmirrors the existingpytest-xdist(0.2.22) andwatchfire(0.2.24) fixes. Build/tooling only — no calculation impact.- Docs now show
uv add rwa-calcas the primary install command, fixing a wrong PyPI package name. The docs landing hero (docs/overrides/main.html) advertisedpip install rwa-calculator— the wrong package name (the project publishes asrwa-calc, notrwa-calculator) — so the copy-to-clipboard command would have failed. It now readsuv add rwa-calc. The getting-started, quickstart and interactive-UI guides are realigned to lead withuv add rwa-calc(pip shown as the secondary option), reflecting uv as the recommended package manager. Docs only — no calculation impact. - Removed the
pip install rwa-calculatorbox from the app landing page (src/rwa_calc/ui/app/templates/landing.html). Anyone viewing the app's landing page is already running the app locally and has therefore already installed it, so the install command was redundant. The box remains on the docs landing page (docs/overrides/main.html), where visitors may not yet have installed the package. UI presentation only — no calculation impact.
[0.2.24] - 2026-06-07¶
Added¶
- New server-rendered read-only UI (
rwa-ui) on a real REST API. The read-only surface (landing, calculator, results explorer, CRR vs Basel 3.1 comparison) is now a pure-Python FastAPI + Jinja application (src/rwa_calc/ui/app/) rendered with the shared--oah-*brand tokens so it matches the Zensical docs, with charts drawn as inline SVG (ui/views/charts.py) — no JavaScript build step, no vendored JS blob, so it bundles cleanly via moonlit. It is backed by a new REST API (src/rwa_calc/api/rest.py, exported ascreate_api_app/api_router):POST /api/calculate,POST /api/validate,GET /api/results,GET /api/results/summary/{class|approach},POST /api/comparison, andGET /api/export/{parquet|csv|excel|corep}over the existingCreditRiskCalc— the library-first contract the UI itself consumes and that external callers can embed. The CRR↔Basel waterfall/transform logic is now a framework-agnostic module (ui/views/comparison.py) shared by the docs, the app, and Marimo. The editable Marimo workbench is retained and launched on demand from the app. UI/API only — no calculation impact. Pinned bytests/integration/test_rest_api.py,tests/integration/test_ui_app.py, andtests/unit/ui/. - Parallel-run reconciliation — compare this calculator's output against a legacy calculator, component by component (migration tooling). Firms adopting the calculator can now reconcile its per-exposure output against their existing engine's output to build migration confidence. A new canonical component registry (
data/schemas.RECONCILABLE_COMPONENTS: exposure class, approach, PD, LGD, maturity, CCF, EAD, risk weight, supporting factor, expected loss, RWA — each with our value column, explain columns and raw input drivers) drivesengine/reconciliation.ReconciliationRunner, which collapses our guarantee/RE sub-rows to the reconciliation grain (engine/aggregator/_collapse.aggregate_to_key_grain, on a defaultexposure_referencekey or a composite/custom key e.g. counterparty + facility), full-outer joins the mapped legacy output, and buckets every mapped component asexact_match/within_tolerance/break/missing_left/missing_right(per-component tolerances default to the acceptance-suite values, overridable). TheReconciliationBundle(contracts/bundles.py) is layered headline → forensic:totals_tie_out+summary_by_component, thensummary_by_bucket/_by_exposure_class/_by_approach, then a rankedbreaks_detailworklist, then a per-keycomponent_reconciliationcarrying legacy-vs-ours, our reason and our input drivers so a break can be triaged to a data fix vs an engine fix. Analyst entry points: a TOML mapping config (stdlibtomllib— no new dependency) viaCreditRiskCalc.reconcile("reconciliation.toml")/api.load_reconciliation_config, aReconciliationResponsewithcollect_*accessors +to_csv/to_excel(multi-sheet) export, and a new Marimo workbook (ui/marimo/reconciliation_app.py, served at/reconciliation) with a live-editable mapping and the four drill-down tiers. Legacy column mapping handles unit scaling (scale, e.g. millions),unit = "percent"ratios, and categoricalvalue_mapsynonyms; non-fatal data-quality issues accumulate asREC001–REC004warnings rather than aborting. Purely additive — no change to any RWA calculation. Pinned bytests/unit/engine/test_collapse.py,tests/unit/engine/test_reconciliation.py,tests/contracts/test_reconciliation_contract.py, andtests/acceptance/reconciliation/test_reconcile_end_to_end.py. - New "Parallel-Run Reconciliation" docs guide (
docs/reconciliation/index.md), surfaced as a top-level nav section. A prominent new-user guide framing reconciliation as the way to gain comfort the calculator produces the right numbers before migrating: a migration-confidence narrative (linked to the parallel-run-discipline blog post), the component/bucket model, how to read the four drill-down tiers (tie-out → by-component → break worklist → per-key forensic), and the full how-to (TOML mapping,CreditRiskCalc.reconcile(), Marimo/reconciliationapp). Threaded into the Overview (Key Features bullet + "Migrate with Confidence" nav card), Getting Started, the Features index, and a cross-link distinguishing it from the CRR↔Basel-3.1 comparison. Docs only — no calculation impact. - New generated "Module Dependencies" docs page (
docs/development/module-dependencies.md). Driven by the newcurfewdev dependency,scripts/generate_dependency_graph.pybuilds the live import graph ofsrc/rwa_calcand renders two Mermaid charts: a readable package-level overview (top-level subpackage edges collapsed from the module graph) and the full 144-module graph in a collapsible block. The generator is wired intoscripts/deploy.pyso the page refreshes on each release, mirroring the Citation Coverage Matrix. Docs/tooling only — no calculation impact.
Changed¶
- The
rwa-uiconsole script now launches the new server-rendered app (rwa_calc.ui.app.main:main), not the Marimo multi-app gateway. Brand design tokens were extracted into a single source of truth,docs/assets/stylesheets/tokens.css(the--oah-*palette, font stack and easing, previously inlined inhomepage.cssand hand-mirrored in the Marimotheme.css), loaded first viazensical.tomlextra_cssand vendored into the app undersrc/rwa_calc/ui/app/static/tokens.css(kept in lockstep by the drift-guard testtests/unit/ui/test_tokens_drift.py). New runtime deps:jinja2,python-multipart; new dev dep:httpx(FastAPITestClient). UI/packaging only — no calculation impact. After upgrading, re-runuv syncto regenerate the console script. watchfireis now in the default-synced[dependency-groups].devso thearch_checkpre-commit gate works on a clean checkout (tooling fix).scripts/arch_check.pyinvokeswatchfire checkas its final step, butwatchfire==0.3.1was declared only in the[project.optional-dependencies].devextra — whichuv syncdoes not install by default — so a plainuv syncleftwatchfireuninstalled andarch_checkfailed withwatchfire not importable: No module named 'watchfire', blocking commits. Addingwatchfire==0.3.1to[dependency-groups].dev(the groupuv syncinstalls by default) makes the citation validator part of the default dev environment, mirroring the existingpytest-xdistfix in 0.2.22. Build/tooling only — no calculation impact.- Renamed the UI console script
rwa-calc-ui→rwa-ui. The[project.scripts]entry point inpyproject.toml(and its references in the README, quickstart, interactive-UI guide, workbooks guide, and interface spec) now uses the shorterrwa-uicommand to launch the Marimo web server (rwa_calc.ui.marimo.server:main). Packaging/UX only — no calculation impact. After upgrading, re-runuv sync(or reinstall) to regenerate the console script; the oldrwa-calc-uiname is removed. - Animated "URSA POLARIS" polar-bear constellation now backs both landing pages. The docs landing hero's static, slowly-rotating generic star-cluster background is replaced by a polar bear drawn as a constellation (stars + bone-lines) that walks across the night sky — STAND → CROUCH → RUN off the right edge → WALK-IN from the left → RISE, over a deterministic twinkling starfield (a nod to "computed at the speed of polars"). It is a single dependency-free vanilla-JS module,
docs/assets/javascripts/bear-constellation.js, that hydrates the first.constellation-bgelement viarequestAnimationFrameand no-ops everywhere that element is absent (so it loads safely site-wide viazensical.tomlextra_javascript); it disables itself underprefers-reduced-motion, rendering one static standing pose. The same effect now also backs the app landing page (src/rwa_calc/ui/app/templates/landing.html), which is reworked into the full-screen constellation hero matching the docs (the four nav cards collapse into the hero nav + CTAs). The landing design system (homepage.css) and the script are vendored intosrc/rwa_calc/ui/app/static/and held in lockstep with theirdocs/sources by the drift-guard testtests/unit/ui/test_tokens_drift.py(now parametrised overtokens.css,homepage.cssandbear-constellation.js);base.htmlgains overridablestyles/topnav/scriptsblocks so the landing page can own the screen without changing the other app pages. UI/docs presentation only — no calculation impact. Pinned bytests/integration/test_ui_app.py::test_landing_hosts_bear_constellation.
[0.2.23] - 2026-06-03¶
Fixed¶
- Facility-level provisions and guarantees now also cascade down nested facility hierarchies (CRR Art. 111(2), 213-217). The same single-level limitation fixed for collateral existed in
engine/crm/provisions.py::_resolve_provisions_multi_level(facility provisions joined on the immediateparent_facility_reference) andengine/crm/guarantees.py::_resolve_guarantees_multi_level(facility guarantees_allocate_guarantees_pro_rataonparent_facility_reference), so a provision or guarantee pledged at a grandparent facility was silently allocated zero to exposures under an intermediate child facility (confirmed: a facility provision at a grandparent allocated 0.0 to both descendant loans). Both now explode the exposure'sancestor_facilitiesset so a provision/guarantee at any ancestor facility is allocated pro-rata (by EAD-equivalent weight /ead_after_collateral) across the whole descendant subtree, stacking across levels, with the same[parent]fallback that keeps single-level behaviour byte-for-byte unchanged. Pinned bytests/unit/crm/test_provisions.py::TestFacilityLevelProvision::test_grandparent_facility_provision_cascadesandtests/unit/crm/test_multi_level_guarantees.py::TestFacilityLevelGuarantee::test_grandparent_facility_guarantee_cascades. Ref: CRR Art. 111(2), 213-217; Art. 230-231. - Facility-level collateral now cascades down nested facility hierarchies (CRR Art. 230-231). Collateral pledged at a facility (
beneficiary_type="facility") was allocated only to exposures whose immediateparent_facility_referencematched the pledged facility:engine/crm/processor.py::_build_facility_lookupgrouped exposure EAD by the immediate parent, andengine/crm/collateral.py::_apply_collateral_unifiedjoined facility collateral onparent_facility_reference == beneficiary_reference. So when collateral sat on a grandparent facility (FAC_1 → FAC_2 → loans/contingents), apledge_percentageresolved against FAC_1's zero direct exposures (→ resolved amount 0) and the allocation join matched nothing → zero collateral allocated, with IRBlgd_post_crmreverting to the unsecured supervisory value. The failure was independent of how the pledge was sized (percentage or explicitmarket_value) and of collateral type (cash, real estate, …). TheHierarchyResolveralready built the facility transitive closure for undrawn-limit aggregation but the CRM stage never consumed it for collateral. Fix:HierarchyResolvernow emits anancestor_facilitieslist column (parent + every ancestor up to root, incl. self) via a new_build_facility_ancestor_closure/_resolve_ancestors_eager;_build_facility_lookupand a new_cascade_facility_collateralconsume it so a pledge at any ancestor facility flows pro-rata (byead_for_crm) to its whole descendant subtree, pool-aware (unflagged collateral stays in the non-AIRB subtree pool per CRR Art. 181) and stacking when pledged at multiple levels. Reduces exactly to the legacy single-level allocation when no facility hierarchy is present (ancestor_facilitiesfalls back to[parent]), so existing single-level facility tests are byte-for-byte unchanged. Pinned bytests/unit/crm/test_multi_level_sa_collateral.py::TestNestedFacilityCollateralCascade(5 SA/FIRB cases incl. grandparent percentage + amount, subtree isolation, stacked pledges),tests/unit/test_hierarchy.py::TestBuildFacilityAncestorClosure(4), andtests/integration/test_nested_facility_collateral.py(2 end-to-end through hierarchy → classifier → CRM). Ref: CRR Art. 223(4), 230-231; Art. 181.
[0.2.22] - 2026-06-02¶
Changed¶
- Collateral-link allocation is now joint and residual-demand-aware across competing collateral items (CRR Art. 230-231). When two or more
collateral_linksitems were pledged to overlapping beneficiaries,CollateralLinkAllocator._allocate_slices(engine/crm/link_allocation.py) split each item independently (a per-collateral_referencecumulative-capcum_sum), with no shared state for demand already absorbed by other items — so every item piled onto the same highest-RWA-density beneficiary, over-allocating it (the downstream Art. 231 waterfall silently caps secured ≤ EAD, wasting the surplus) while starving the others. Example: two £100m cash items each linked to F1 (RW 37%) and F2 (RW 40%) both dumped £100m on F2 → F2 covered 200/100 (£100m wasted), F1 received £0, total recognised benefit £100m. The split is now a single global edge-ordered walk (_residual_fill) that fills each link against the residual of both endpoints — item finite value (supply) and beneficiary demand — so once F2 is filled by one item the other spills to F1 (both fully covered, £0 wasted, benefit £200m). Edge order is unchanged where it mattered before (explicitpriorityfirst, then descending RWA density, then lexical tie-break) with a new most-constrained-item tie-break (an item linked to fewer beneficiaries draws first) so restricted-eligibility supply is not stranded by a more flexible item. A deterministic greedy heuristic, not a global LP optimum; reduces exactly to the legacy cumulative-cap split for any single-item link set, so the existing 7 allocator tests and all 3 integration scenarios pass byte-for-byte unchanged. The per-edge supply cap preserves theΣ slices ≤ valueguarantee;max_pledge_amountandprioritysemantics are unchanged. Gated, as before, byCalculationConfig.enable_collateral_link_splitting(defaultTrue); a corpus with nocollateral_linkstable is unaffected. Pinned by 3 new tests intests/unit/crm/test_collateral_link_allocation.py(test_two_items_two_facilities_joint_fill,test_constrained_item_not_stranded,test_duplicate_link_no_double_spend). Ref: CRR Art. 230-231. pytest-xdistmoved into the default-synced[dependency-groups].devso the configuredaddoptsparse on a clean checkout (release/tooling fix).[tool.pytest.ini_options].addoptsunconditionally passes-n auto --dist=loadfile, butpytest-xdistwas declared only in the[project.optional-dependencies].devextra — whichuv syncdoes not install by default — so a freshuv syncproduced an environment where every bareuv run pytest(includingscripts/deploy.py'suv run pytest -x -qrelease gate) aborted witherror: unrecognized arguments: -n --dist=loadfile. Addingpytest-xdist>=3.5.0to[dependency-groups].dev(the groupuv syncinstalls by default) makes the parallel-test plugin part of the default dev/release environment, matching the existing optional-extra declaration. Tooling/build only — no calculation impact.
[0.2.21] - 2026-06-01¶
Added¶
collateral_linksis now auto-discovered from the standard data layout via the data-source registry. The optionalcollateral_linksinput (the M:N collateral-to-beneficiary mapping shipped in v0.2.20,COLLATERAL_LINK_SCHEMA) was fully wired throughDataSourceConfig.from_registry()and_build_bundleinengine/loader.py, but had no entry in theDATA_SOURCESregistry (config/data_sources.py) — soget_p("collateral_links")always resolved toNoneand the table was never picked up from the conventional layout; a caller had to setcollateral_links_fileby hand. A newDataSourceFile(id="collateral_links", relative_path=Path("collateral/collateral_links"), OPTIONAL)entry (mirroring the siblingcollateralsource) now letsDataSourceConfig.from_registry()resolvecollateral_links_filetocollateral/collateral_links.parquet(or.csv) automatically, exactly like every other optional input. Purely additive and behaviour-preserving — firms with nocollateral_linkstable still take the single-beneficiary path (loader returnsNonegracefully when the file is absent). Pinned bytests/unit/config/test_data_sources_collateral_links.py(7 tests: registry entry presence, relative path, OPTIONAL requirement, parquet appears inget_optional,from_registryparquet/csv population, description). Ref: CRR Art. 230-231.
[0.2.20] - 2026-05-31¶
Added¶
- Collateral M:N allocation — one finite collateral item split across multiple beneficiaries (CRR Art. 230-231). Collateral could previously attach to a single
beneficiary_referenceonly; a real-world pledge backing several facilities/loans had no representation. A new optionalcollateral_linksinput table (COLLATERAL_LINK_SCHEMAindata/schemas.py:collateral_reference,beneficiary_type,beneficiary_reference, optionalmax_pledge_amountsub-limit andpriorityoverride) maps one collateral item to many beneficiaries. A newCollateralLinkAllocator(engine/crm/link_allocation.py, implementingCollateralLinkAllocatorProtocol) splits each item's finite value across its linked beneficiaries for the most beneficial RWA impact — a greedy fill of the highest pre-CRM RWA-density beneficiary first (SA-equivalent risk weight computed by a ranking pre-pass inside the CRM stage), honouring anymax_pledge_amountcap, and never over-claiming (Σ slices ≤ value) via the same Art. 231 cumulative-cap trick the waterfall uses (slice_i = min(cum_i, V) − min(prev_i, V)). The allocator expands the links into per-beneficiary collateral rows of the normalCOLLATERAL_SCHEMAshape, so the existingapply_collateralwaterfall, EAD/LGD reduction, and SA/IRB consumers are unchanged. All five beneficiary types resolve (loan/contingent/exposure direct, facility/counterparty pooled). Threaded additively throughRawDataBundle/ResolvedHierarchyBundle/ClassifiedExposuresBundleand surfaced onCRMAdjustedBundle.collateral_link_allocation(per-link audit). Referential integrity (validate_collateral_linksincontracts/validation.py: unknown collateralCRM009, unknown beneficiaryCRM010, duplicate linkCRM011). Gated byCalculationConfig.enable_collateral_link_splitting(defaultTrue; an A/B kill-switch). Purely additive — a corpus with nocollateral_linkstable behaves exactly as the single-beneficiary path (full suite 7178 green). Pinned bytests/unit/crm/test_collateral_link_allocation.py(7 allocator tests: finite-value split, RWA-minimising order, no-overclaim,max_pledge_amountcap, priority override, passthrough, mixed beneficiary types),tests/unit/contracts/test_validation_collateral_links.py(6),tests/unit/data/test_collateral_link_schema.py(6), andtests/integration/test_collateral_links_pipeline.py(3 end-to-end: one £1m cash item linked to a 0% sovereign loan and a 100% corporate loan lands entirely on the corporate loan, proving the RWA-ranking drives the split rather than the lexical tie-break). Ref: CRR Art. 193/194/207, Art. 230-231.
[0.2.19] - 2026-05-30¶
Added¶
- P4.20 (v0.2.45) — COREP C 08.02 now keys IRB rows by the firm's own internal rating grade when an
internal_rating_grade(RATINGS_SCHEMA) /cp_internal_rating_grade(HIERARCHY_OUTPUT_SCHEMA) column is supplied, with graceful fallback to the fixed PD buckets when absent/all-null. Per COREP Annex II §C 08.02 (one row per obligor grade); reporting-granularity only, no RWA impact. Pinned bytests/unit/test_p4_20_c0802_internal_grades.py(19 tests; existing fixed-bucket output byte-identical —tests/unit/test_corep.py711 passed). - P2.25(b) (v0.2.45) — Pillar III CR5 now reports a regulatory-RE not-materially-dependent loan split at the 55% LTV boundary: new Basel-3.1 "of which" sub-rows 9f (secured ≤55% LTV @20%) / 9g (above-55% residual @counterparty RW), partitioned by
re_split_role. Per PRA PS1/26 Annex XX §CR5 (Art. 124F/124L); CRR CR5 byte-identical, sub-rows not double-counted into the grand total. Pinned bytests/unit/reporting/pillar3/test_p2_25_cr5_re_55ltv_split.py(11 tests). Sub-item (c) equity-transitional end-state RW deferred (needs an engineequity_rw_end_statecolumn; overlaps P3.6b). - P1.188 — Stale
PS9/24regulatory-instrument citations replaced with the finalPS1/26. The post-model-adjustment docstrings/comments incontracts/config.py,data/schemas.py,engine/irb/adjustments.pyand thepyproject.tomlpackage description still citedPS9/24(the superseded 2024 PRA consultation paper), whose numbering was reassigned toPS1/26on publication of the final policy statement (effective 1 Jan 2027). Five in-scope source occurrences plus the package description were corrected; article numbers (Art. 153(5A)/154(4A)/158(6A)) and surrounding text are unchanged. Cosmetic/metadata only — no calculation impact. Pinned bytests/contracts/test_ps126_citation_currency.py(6 parametrized:PS9/24absent andPS1/26present per source file). Ref: PRA PS1/26 (final); PS9/24 (consultation — superseded). - P6.21 —
_compute_portfolio_waterfallis now fully lazy (LazyFrame-first contract). The capital-impact waterfall builder collected its six aggregate sums mid-pipeline (engine/comparison.py), materialising a frame before the output boundary in violation of the LazyFrame-first convention. The.collect()was removed and the 4-row waterfall re-expressed with apl.LazyFramescaffold + cross-join +when/thenso the function returns a genuineLazyFrame(no.collect().lazy()shape — arch_check check-3 clean). Values byte-identical. Pinned bytests/unit/test_comparison_waterfall_lazy.py(no-eager-collect + return-type + value-invariance), withtests/unit/test_capital_impact.pyas the invariance net. Ref: CLAUDE.md Polars Conventions (LazyFrame-first). - P6.33 —
compute_pfedelegates unmargined replacement cost to the canonicalcompute_rc_unmargined(CRR Art. 275(1)). The SA-CCR PFE builder (engine/ccr/pfe.py) inlinedmax(V_net − C_net, 0)as a duplicate of the canonicalcompute_rc_unmarginedinengine/ccr/rc.py, so a future change to the RC kernel would have to be applied twice.compute_pfenow callscompute_rc_unmarginedbefore composing the multiplier/EAD, preserving thehas_unified_rc/rc_for_eadcoalesce; the@cites("CRR Art. 278")decorator is unchanged. EAD/PFE values invariant — no calculation impact. Pinned bytests/unit/ccr/test_pfe_rc_delegation.py(delegation contract + NS-P6.33-01 golden), withtests/unit/ccr/test_pfe_multiplier.pyas the invariance net. Ref: CRR Art. 275(1). - P2.38 — CRR Art. 155(2) non-trading-book short-position netting for the IRB Simple equity method (CRR-only). Long and short positions in the same issuer were not netted before applying the simple equity risk weight, so a short position was treated as a standalone long and equity RWA was overstated. New signed
position_valueplusissuer_referenceandis_explicitly_hedgedcolumns onEQUITY_EXPOSURE_SCHEMAdrive a new helperengine/equity/calculator.py::_net_short_positions(@cites("CRR Art. 155(2)")): per issuer the netted long =max(0, Σ position_value)carries the netted EAD and the absorbed short → EAD/RWA 0, gated on a non-null issuer key +is_explicitly_hedged(≥1-year explicit hedge). CRR-only — Basel 3.1 routes equity to SA (PS1/26 Art. 147A), so the B31 arm never reaches the netting branch; column-absent frames are unchanged. The ≥1-year hedge-tenor floor scalarCRR_EQUITY_NETTING_MIN_HEDGE_YEARSlives indata/tables/crr_equity_rw.py(no new engine-scope scalar). Pinned bytests/acceptance/crr/test_p2_38_art_155_2_short_position_netting.py(8 tests; worked case: long £1,000,000 + short £400,000 in ISSUER-A → netted EAD £600,000 × 290% = RWA £1,740,000, vs the un-netted £4,060,000 the pre-fix engine produced). Ref: CRR Art. 155(1)-(2), Art. 165. - P2.46 — CRR Art. 150(1) PPU provenance enum so COREP C 07.00 can distinguish the three SA-routing reasons. Column 0050 ("Standardised Approach — of which permanent partial use") was indistinguishable from Art. 148 sequential roll-out (row 0060) and from no-permission SA, because the model_permissions input carried no provenance. New
PpuReasonStrEnum (art_150_1_a..j+art_148_rollout,domain/enums.py); a newppu_reasoncolumn onMODEL_PERMISSIONS_SCHEMAandCLASSIFIER_OUTPUT_SCHEMAthreaded throughengine/classifier.py::_resolve_model_permissionsonto the surviving SA-precedence row;reporting/corep/generator.pyroutes C 07.00 row 0050 (ppu_reason ∈ art_150_1_*) and row 0060 (art_148_rollout) with a graceful fallback that preserves the prior null behaviour when the column is absent (so the ~700 existing COREP tests stay green). Also adds the required"standardised"value toVALID_MODEL_PERMISSION_APPROACHES(the classifier already testedmp_approach == ApproachType.SA.value, but the constraint set rejected it). Art. 150(2) firm-level equity materiality is an explicit non-goal. Provenance-only — RWA/EAD-conservation-neutral. Pinned bytests/acceptance/crr/test_p2_46_ppu_provenance_corep.py(8 tests: three corporate SA exposures → C 07.00 rows 0050/0060/residual = £1m/£1m/£1m of total SA £3m). Ref: CRR Art. 150(1)(a)-(j), Art. 148; COREP Annex II §C 07.00. - P2.48 — Pillar III CR8 RWEA-flow opening and residual rows now populated (PRA PS1/26 Annex XXII §11 / CRR Art. 438(h)). The CR8 flow statement only populated the closing row; rows 1 (opening) and 2-8 (flow drivers) emitted
Nonefor lack of prior-period data. A new optionalprevious_period_results: pl.LazyFrame | Noneparameter onPillar3Generator.generate/generate_from_lazyframe(following the P2.29output_floor_summaryprecedent — nocontracts/bundles.pychange) lets_generate_cr8derive row 1 (opening = prior-period IRB-non-slottingrwa_finalsum, via the same_filter_irb_non_slotting+_col_sumas the closing row, so the two snapshots reconcile like-for-like) and row 8 (Other = closing − opening, a signed Float64 — increases positive, decreases negative); rows 2-7 remainNone(per-driver decomposition needs exposure-level lineage not derivable from two snapshots, and is out of scope). Backwards-compatible: omitting the parameter leaves rows 1-8None. Pinned bytests/unit/reporting/pillar3/test_p2_48_cr8_rwea_flow.py(9 tests: opening £1,000,000, residual +£150,000, closing £1,150,000; −£150,000 decrease control; reconciliationrow_1 + row_8 == row_9). Ref: PRA PS1/26 Annex XXII §11; CRR Art. 438(h). - P2.15 — Basel 3.1 equity transitional irrevocable opt-out election (PRA PS1/26 Rules 4.9-4.10).
EquityTransitionalConfighad no flag for a firm that has irrevocably elected to leave the equity transitional schedule, so such a firm could not be modelled at end-state risk weights during the 2027-2029 phase-in. A newopt_out: bool = Falsefield (contracts/config.py) now gates both transitional gates per Rule 4.9's joint election:engine/equity/calculator.py::_equity_holding_higher_of_rwreturnsNone(a CIU look-through equity underlying reverts from the 370% legacy Art. 155(2) higher-of to the 100% default holding RW) and_apply_transitional_floorearly-returns unchanged (direct equity keeps its end-state assigned RW),@cites("PS1/26, paragraph 4.9"). The CIU higher-of mechanic the opt-out suppresses shipped earlier under P1.139. Behaviourally inert whenopt_out=False(the default), so existing runs are unchanged. Pinned bytests/acceptance/basel31/test_p2_15_equity_transitional_optout.py(8 cases: CIU look-through RW 3.70 / RWA £3,700,000 atopt_out=False→ RW 1.00 / RWA £1,000,000 atopt_out=True; direct LISTED control RW 2.50 / RWA £2,500,000 invariant under both). Ref: PRA PS1/26 Rules 4.9-4.10; Art. 133, CRR Art. 155(2). - P2.41 —
exposure_subclassderivation for the Art. 147A(1)(e)/(f) COREP corporate split. The COREP C 02.00 corporate sub-rows (0295 financial/large, 0296 SME, 0297 other non-SME) were split on ais_fse = apply_fi_scalar OR cp_is_financial_sector_entityheuristic that misrouted large corporates by revenue (the Art. 153(2) FI-scalar population is threshold-gated LFSEs, which is the wrong population for the Art. 147A(1)(e) subclass). A newExposureSubclassStrEnum (domain/enums.py) and classifier-derivedexposure_subclasscolumn onCLASSIFIER_OUTPUT_SCHEMAnow label corporate exposures:engine/classifier.py::_derive_exposure_subclass(Basel-3.1-only, CRR → null;@cites("PS1/26, paragraph 147A.1")) routes FSE orcp_annual_revenue > config.thresholds.large_corporate_revenue_threshold(GBP 440m) tocorporate_financial_large(Art. 147A(1)(e)), SME tocorporate_sme, elsecorporate_other(Art. 147A(1)(f)).reporting/corep/generator.py::_c02_00_irb_sub_aggconsumes the new label (with a graceful fallback to the prior heuristic when the column is absent). Reporting-granularity only — RWA-conservation-neutral (fulltests/unit/test_corep.pystays 706 green). Pinned bytests/acceptance/basel31/test_p2_41_exposure_subclass_corep.py(7 tests: row 0295 == RWA(FSE) + RWA(large-corp-by-revenue) and strictly > RWA(FSE) alone, row 0297 residual 0, F-IRB conservation). Ref: PRA PS1/26 Art. 147A(1)(e)/(f), Art. 147(2)(c)(ii)/(iii); COREP Annex II §C 02.00. - P2.47 — Art. 137 OECD MEIP direct sovereign risk weight now applies on the Basel 3.1 arm (capital-overstatement fix). The Art. 137 Table 9 ECA/MEIP-score → risk-weight path was implemented on the CRR arm (P1.100) but
engine/sa/namespace.py::_apply_b31_risk_weight_overrideslacked thecp_eca_scorebranch, so an unrated Basel-3.1 sovereign carrying an ECA/MEIP score fell through to the Art. 114 unrated 100% default — e.g. a score-2 sovereign was risk-weighted 100% instead of 20%, a 5× overstatement — even though PS1/26 leaves sovereign treatment unchanged from CRR. The B31 override chain now carries the samecp_eca_scorebranch as the CRR sibling (ordered below the Art. 114(3)/(4) domestic-currency 0% override and above the unrated fallback), reusing the existingECA_MEIP_RISK_WEIGHTSdata table via_eca_meip_rw_expr()— no new scalar, no schema change —@cites("CRR Art. 137"). Additive: a sovereign with no MEIP score keeps its current behaviour. Pinned bytests/acceptance/basel31/test_p2_47_art137_meip_b31.py(unrated B31 sovereign, MEIP score 2, USD £5,000,000 → RW 0.20 / RWA £1,000,000; anti-confound RW ≠ 1.00). Art. 136 grade-string→CQS, Art. 138 second-best, and Art. 121 sovereign-derived institution propagation remain open follow-ons. Ref: CRR Art. 137(1)-(2) Table 9; PRA PS1/26 Art. 114. - P2.30 — CCF Annex I Row 3 / Row 4 are now distinguishable for COREP disclosure.
domain/enums.pyexposed only sixRiskTypevalues, collapsing CRR Annex I Row 3 (other issued off-balance-sheet items, medium-risk) and Row 4 (NIFs/RUFs) onto the singleMRvalue — so the C 07.00 off-balance-sheet-by-CCF section, which buckets purely on the numericccf_applied, could not separate the two rows for Annex I-faithful disclosure. A newRiskType.MR_ISSUED = "medium_risk_issued"(Row 3) is now distinct fromMR(Row 4) but resolves to an identical 50% SA CCF (and matching F-IRB behaviour), so RWA/EAD are provably unchanged — the change is identifier-separability only. Threaded throughdata/schemas.py(VALID_RISK_TYPES_INPUT+RISK_TYPE_SYNONYMSmr_issued/medium_risk_issued→MR_ISSUED) anddata/tables/ccf.py(explicitMR_ISSUED: 0.50inSA_CCF_CRR/SA_CCF_B31/FIRB_OBS_FALLBACKandis_mr_or_ocmembership so the F-IRB issued/commitment split mirrorsMRexactly). The concrete-product →risk_typederivation table (P2.31) and the optional C 07.00 "of which" sub-row remain out of scope. Pinned bytests/unit/test_corep_annex1_row_discrimination.py(11 tests: enum/VALID_RISK_TYPES_INPUT/synonym existence RED→GREEN + CCF 0.50 / EAD £500,000 invariance for both rows; the pre-existingtest_ccr_schemas_contract.pycount andtest_ccf_tables.pyexact-table assertions were widened for the additiveMR_ISSUEDentry). Ref: CRR Annex I, Art. 111. - P3.5 — Pillar III CR9.1 (ECAI-based PD back-testing) is now callable end-to-end (Basel 3.1 only). The
CR9_1_COLUMNStemplate existed but had no generator method, so the template was defined-but-not-callable (P3.2 was marked complete prematurely).reporting/pillar3/generator.pygains_generate_cr9_1+_generate_cr9_1_for_class+_cr9_1_schemaand a newcr9_1: dict[str, pl.DataFrame]field on the reporting-sidePillar3TemplateBundle(not the corecontracts/bundles.py), wired intogenerate_from_lazyframe. CR9.1 is Basel-3.1-only (CRR →{}): it filters ECAI-mapped obligor rows (ecai_pd_mapping, Art. 180(1)(f)), groups byexternal_rating_equivalent, and reuses the existing_compute_cr9_valuesfor the back-testing columns c–h (obligor counts, observed default rate, EAD-weighted and arithmetic average PD, historical default rate). Multi-ECAI column fan-out and Excel-sheet export remain out of scope. Pinned bytests/unit/reporting/pillar3/test_p3_5_cr9_1_ecai_backtesting.py(25 tests; seeded results fixturetests/fixtures/p3_5/; key"advanced_irb - corporate", height 3, ECAI grades "A"/"BBB", CRR framework-gate, and a CR9 regression guard). Ref: PRA PS1/26 Art. 452(h), Art. 180(1)(f), Annex XXII §15. - P6.25 —
CalculationConfig.crr()now exposes theairb_collateral_methodknob, matchingbasel_3_1()(config-API symmetry). The A-IRB collateral-method selector existed as a field onCalculationConfigand was settable viaCalculationConfig.basel_3_1(), but thecrr()factory neither accepted nor set it, socrr().airb_collateral_methodsilently returnedNoneinstead of a framework-appropriate default — an asymmetry that could surprise a caller constructing a CRR config and reading the field.crr()now acceptsairb_collateral_method: AIRBCollateralMethod = AIRBCollateralMethod.LGD_MODELLINGand threads it into the returned config (CRR A-IRB recognises collateral inside the firm's own modelled LGD per CRR Art. 161/181 — the own-LGD treatment PS1/26 Art. 169A namesLGD_MODELLING;FOUNDATIONis the F-IRB supervisory-LGD substitution and is the wrong default for A-IRB). The change is behaviourally inert on the CRR calculation path: every read ofairb_collateral_methodinengine/crm/collateral.pyisis_basel_3_1-gated, so RWA/EAD/LGD are unchanged; only the reported config default moves fromNonetoLGD_MODELLING. The dataclass field default (= None) is left untouched so rawCalculationConfig(...)construction keeps its "not-set" semantics. Pinned bytests/unit/crm/test_art169_lgd_modelling.py::TestConfigAndEnum(3 tests:crr()default ==LGD_MODELLING, explicit override pass-through ==FOUNDATION, and abasel_3_1()symmetry regression-lock; the staletest_crr_config_no_airb_methodis Noneassertion was flipped). Ref: CRR Art. 161/181; PRA PS1/26 Art. 169A. - P1.141 — Basel 3.1 mixed real-estate exposures now enforce the Art. 124(4) all-or-nothing qualifying gate. The pro-rata-by-collateral-value split of a mixed RRE/CRE exposure was already implemented, but the second limb of Art. 124(4) — the preferential Art. 124F–124I real-estate tables apply only if both the residential and the commercial component separately qualify under Art. 124A, otherwise both fall to Art. 124J (no partial preference) — was not enforced, so a mixed exposure whose commercial component failed Art. 124A still received the residential 20% preferential band (capital understatement).
engine/hierarchy.pynow aggregates a per-exposurere_collateral_non_qualifyingflag and preserves uncapped residential/commercial collateral values before the retail-threshold cap (which was distorting the split shares when total collateral exceeded the exposure);engine/classifier.pyemits a Basel-3.1-onlyre_split_force_other_re = is_mixed & re_collateral_non_qualifying(@cites("PS1/26, paragraph 124.4"); the CRR path stayspl.lit(False), unchanged);engine/re_splitter.pythen routes both secured rows through Art. 124J (b31_other_re_rw_expr: RESI → counterparty RW, CRE → max(60%, counterparty RW)) at full pro-rata EAD, dropping the 0.55×value cap so the residual is zero. No new regulatory scalar (reusesB31_OTHER_RE_CRE_FLOOR_RW). Worked golden: an unrated-corporate £2,000,000 mixed exposure (£1.5m qualifying residential + £1.0m non-qualifying commercial collateral) now risk-weights both legs at 100% → RWA £2,000,000 (vs the pre-fix £1,340,000 that retained the residential 20% band). Pinned bytests/acceptance/basel31/test_p1_141_art_124_4_all_or_nothing_gate.py(9 tests). Ref: PRA PS1/26 Art. 124(4), Art. 124A, Art. 124J. - P2.32 — Basel 3.1 CCF for undrawn purchased-receivables purchase commitments (Art. 166E(5)).
engine/ccf.pyhad no purchased-receivables branch, so an undrawn revolving purchase commitment fell through to the generic risk-type CCF ladder (e.g. a medium-risk commitment took 50% instead of the regulatory 40%). A newis_purchased_receivable_commitmentboolean onFACILITY_SCHEMA/CONTINGENTS_SCHEMA(threaded throughengine/hierarchy.pymirroringis_uk_residential_mortgage_commitment) drives a Basel-3.1-gated override in_compute_ccf(_apply_purchased_receivable_ccf,@cites("PS1/26, paragraph 166.5")): a revolving purchase commitment converts at 40% (Table A1 Row 5 "other commitments") or 10% where it meets the Row 7 unconditionally-cancellable criteria, reusingSA_CCF_B31["OC"]/["LR"](no new scalar). The override is a no-op under CRR (which has no dedicated purchased-receivables undrawn-commitment CCF). The dilution/default split and the Art. 166A(5) EAD netting remain separate, out-of-scope mechanisms. Pinned bytests/acceptance/basel31/test_p2_32_purchased_receivable_ccf.py(7 tests; load-bearing: a medium-risk-tagged revolving purchase commitment of £1,000,000 → CCF 40% / EAD £400,000, overriding the generic 50% / £500,000; CRR control unchanged). Ref: CRR Art. 166(5), PRA PS1/26 Art. 166E(5). - P5.15 — Basel 3.1 retail qualification now enforces the Art. 123A(1)(b)(ii) 0.2% granularity sub-condition. The regulatory-retail check previously enforced only the GBP 880k aggregate-exposure limb; the second limb of the same sub-paragraph — no single obligor may exceed 0.2% of the total retail portfolio — was unimplemented, so a concentrated natural-person exposure was risk-weighted at the 75% retail RW when it should fall to the 100% corporate RW (capital understatement). New
B31_RETAIL_GRANULARITY_LIMIT = Decimal("0.002")indata/tables/b31_risk_weights.py;engine/classifier.py::_build_qualifies_as_retail_exprgains a Basel-3.1-only granularity limb after the SME auto-qualify and GBP 880k threshold branches — a candidate-retail obligor whose per-obligor aggregate (lending_group_adjusted_exposure) exceeds 0.2% of the total candidate-retail portfolio (denominator de-duplicated to one contribution per obligor viapl.len().over("counterparty_reference")wrapped inpartition_by_nullable, guarded against a zero portfolio total) failsqualifies_as_retailand re-routes to CORPORATE. The limb is gated on a newCalculationConfig.enforce_retail_granularityflag (defaultTrue; settable viabasel_3_1(enforce_retail_granularity=...)) so it can be suppressed where a firm assesses portfolio granularity by another method under CRE20.66's national-discretion clause — and so isolated single-obligor tests of the other Art. 123A limbs (pool management, GBP 880k threshold) are not spuriously re-classed (a single obligor is trivially 100% > 0.2%). The CRR Art. 123 branch stays threshold-only. Pinned bytests/acceptance/basel31/test_p5_15_art_123a_granularity.py(19 tests; breach obligor → CORPORATE RW 1.00 / RWA £2,000 with the load-bearing anti-confound that its aggregate is below GBP 880k so only the granularity limb re-classed it; control-pass obligor stays retail RW 0.75 / RWA £750). Ref: PRA PS1/26 Art. 123A(1)(b)(ii); BCBS CRE20.66. - P2.29 — Pillar III OV1 equity sub-approach and output-floor rows are now populated (PRA PS1/26 Annex XX §OV1). B31 OV1 rows 11-14 (equity under IRB Transitional / look-through / mandate-based / fall-back) and rows 26/27 (output-floor multiplier / OF-ADJ) were hard-coded null despite the pipeline carrying the source columns, so the disclosure could not reproduce the Annex XX template.
reporting/pillar3/generator.pyreplaces the catch-all_OV1_EXPLICIT_NULL_REFSwith_OV1_FLOOR_NO_SHIM_REFS={"26","27"}plus an_OV1_EQUITY_SUBAPPROACH_REFSdiscriminator map: rows 11-14 sumrwa_finaloverapproach_applied="equity"and the row'sequity_transitional_approach/ciu_approachdiscriminator (own-funds column c = 8% × column a); row 26 reads the first non-nulloutput_floor_pct; row 27 readsOutputFloorSummary.of_adjvia a new optionalgenerate_from_lazyframe(output_floor_summary=...)parameter (defaultNone, so the parquet-backedgenerate()path and all existing callers are unaffected; rows 26/27 are excluded from the column-c shim). Rows 11-14 are memo "of which" sub-rows of equity already counted in row 2 and are deliberately not folded into the row-29 grand total. Pinned bytests/unit/reporting/pillar3/test_p2_29_ov1_equity_subapproach.py(15 tests, Python-only seeded-results fixture +OutputFloorSummary). Ref: PRA PS1/26 Annex XX §OV1. - P1.153 — CRR Art. 155(3) PD/LGD equity approach (CRR-only; removed under Basel 3.1). The IRB PD/LGD method for equity exposures was entirely absent — equity routed only to SA (Art. 133) or IRB Simple (Art. 155(2)). New
EquityApproach.PD_LGDenum (domain/enums.py) and acrr_equity_pd_lgd.pydata table carrying the Art. 165 supervisory parameters: PD floors 0.09%/0.09%/0.40%/1.25% (Art. 165(1)(a)-(d)), LGD 90% (65% for diversified private equity, Art. 165(2)), M=5y (Art. 165(3)), and the Art. 155(3) 1.5× scaling applied when the firm lacks Art. 178 default-definition data. A newequity_pd_lgd: boolflag onCalculationConfigselects the approach (gated to CRR with IRB permissions; ignored under Basel 3.1, where IRB equity is removed by PS1/26 Art. 147A), and a newhas_default_definition_infocolumn onEQUITY_EXPOSURE_SCHEMAdrives the 1.5× branch.engine/equity/calculator.py::_apply_equity_weights_pd_lgdreuses the shared corporate IRB primitives (engine/irb/formulas.py) for correlation/K/maturity-adjustment, then appliesRWEA = K×12.5×1.06×MA×EAD, the per-exposure capmin(RWEA, EAD×12.5 − EL×12.5), and bypasses the Simple-approach transitional floor. Pinned bytests/acceptance/crr/test_p1_153_art_155_3_pdlgd_equity.py(14 tests; worked exchange-traded exposure of EAD £1,000,000 → risk weight 1.918731, RWA £1,918,731, K 0.0736714, cap non-binding). Ref: CRR Art. 155(3), Art. 165. - P1.30(e) — Art. 234 partial-protection (mezzanine) tranching of credit protection. Previously every guarantee attached to first loss
[0,G), leaving a single senior remainder; protection covering only a middle loss band[a,d)(with the borrower retaining both a first-loss tranche[0,a)and a senior tranche[d,EAD]at its own risk weight) was not modelled. New optionalattachment_amount/detachment_amountcolumns onGUARANTEE_SCHEMAlet protection attach to a mezzanine band;engine/crm/guarantees.py::_build_remainder_sub_rows(now@cites("CRR Art. 234")) emits a three-row split (first-loss__REM_FL+ guarantor-substituted mezzanine + senior__REM_SEN) whenattachment_amount > 0, composing after the existing FX/restructuring/maturity-mismatch haircuts; a null attachment preserves the legacy single-__REMfirst-loss behaviour byte-for-behaviour. Theredistribute_non_beneficialremainder predicate was loosened (ends_with→contains("__REM")) so both retained tranches are recognised. Pinned bytests/acceptance/crr/test_p1_30e_art_234_partial_protection_tranching.py(11 tests: three-row structure, per-tranche EAD 200k/400k/400k, ΣEAD conservation = £1,000,000, exactly-one-guarantor-row, blended RW 0.80). Ref: CRR Art. 234, PRA PS1/26 Art. 234. - P1.94(e) — Basel 3.1 currency-mismatch 1.5× multiplier is now suppressed for pre-2027 reporting dates (PRA PS1/26 Art. 123B(3) transitional). The Art. 123B 1.5× multiplier is a Basel-3.1 measure commencing 1 January 2027, but the engine applied it whenever
is_basel_3_1was set, so a Basel-3.1-configured run dated before commencement over-weighted unhedged FX-mismatched retail/RE exposures. A newB31_EFFECTIVE_DATE = date(2027, 1, 1)indata/tables/b31_risk_weights.pyplus a strictif config.reporting_date < B31_EFFECTIVE_DATEshort-circuit at the head ofengine/sa/namespace.py::apply_currency_mismatch_multiplier(after the existingis_basel_3_1guard) now returns the frame unchanged — emittingcurrency_mismatch_multiplier_applied = Falseso downstream reporting always sees the flag — for reporting dates strictly before 1 Jan 2027 (the boundary date itself is in scope).@cites("PS1/26, paragraph 123B.3"). Pinned bytests/acceptance/basel31/test_p1_94e_pre_2027_fallback.py(7 tests: Run A 2026-12-31 → RW 0.75 / RWA £75,000, multiplier suppressed; Run B 2027-01-01 → RW 1.125 / RWA £112,500, multiplier fires). Ref: PRA PS1/26 Art. 123B(3). - P2.31 — Annex I concrete-product →
risk_typemapping table (eliminates manual OBS-item classification). The CCF engine mapped abstract Annex Irisk_typebuckets to conversion factors but had no table translating concrete off-balance-sheet product descriptions (acceptances, performance bonds, warranties, tender bonds, documentary credits) into those buckets, leaving users to hand-classify every contingent — a silent-misclassification risk. A new input-domainobs_productcolumn onFACILITY_SCHEMA/CONTINGENTS_SCHEMA(data/schemas.py, withVALID_OBS_PRODUCTS+OBS_PRODUCT_SYNONYMSnormalisation) feeds a single, framework-invariantANNEX1_PRODUCT_RISK_TYPEdict +build_product_to_risk_type_exprindata/tables/ccf.py(ACCEPTANCE → FR/100%; performance/tender/bid bonds, warranties, documentary/trade credits → MLR/20% — all framework-invariant under SA Table A1, so the framework split stays solely in the untouchedSA_CCF_CRR/SA_CCF_B31).engine/ccf.py::_compute_ccffillsrisk_typefromobs_productonly when no explicitrisk_typeis supplied (explicit always wins),@cites("CRR Art. 111"). Regulatory note: under SA Annex I, performance bonds are MLR/20%; the 50% sometimes cited is the F-IRB Art. 166(10)(b) treatment, not the SA bucket. Pinned bytests/acceptance/crr/test_p2_31_annex1_mapping.py(10 tests: ACCEPTANCE → CCF 1.00 / EAD £2,000,000; PERFORMANCE_BOND and DOCUMENTARY_CREDIT → CCF 0.20 / EAD £400,000; explicit-LR override preserved). Ref: CRR Annex I, Art. 111. - P2.49 — Pillar III CR9 column-
ataxonomy extended to the full PRA PS1/26 Annex XXII leaf set. The CR9 (IRB PD back-testing) disclosure collapsed the regulatory column-abreakdown — F-IRB lacked the financial/large-corporate and other-corporate (non-SME) sub-classes, and A-IRB collapsed seven retail/corporate sub-classes intoretail_mortgage/retail_qrre/retail_other.CR9_FIRB_CLASSESnow carries 5 leaves (addedcorporate_financial_largeper Art. 147(2)(c)(ii) F-IRB-only andcorporate_other_non_sme) andCR9_AIRB_CLASSES10 (retail RRE/CRE × SME/non-SME, QRRE, Other × SME/non-SME, plus the corporate splits). To respect the data/engine boundary the row definition became a 3-tuple(row_key, label, CR9ClassSpec)whereCR9ClassSpecis a plain frozen dataclass —reporting/pillar3/templates.pystays import-clean (no Polars; arch_check-enforced) — and the descriptor→pl.Exprresolution (_cr9_class_predicate, discriminating onexposure_class/is_sme/property_type/cp_is_financial_sector_entitywith graceful degradation when a discriminator is absent) lives inreporting/pillar3/generator.py, applied to both_generate_all_cr9and_generate_cr9_1.@cites("PS1/26, paragraph 147.2"). Supersedes P2.28. Pinned bytests/unit/reporting/pillar3/test_p2_49_cr9_taxonomy.py(13 tests: leaf counts 5/10, 15 expected keys, collapsed-parent absence, discriminator routing); the existingtest_pillar3.pyCR9 count/key/value tests and the_make_cr9_irb_datafixture were updated for the new taxonomy (full pillar3 surface: 256 tests green). Ref: PRA PS1/26 Annex XXII pp.19-20, Art. 147(2).
Changed¶
- P1.94(d) — Basel 3.1 currency-mismatch 1.5× multiplier now applies the Art. 123B(2A) revolving-instalment rule. The Art. 123B(2) 90%-hedge-coverage waiver previously measured coverage against a revolving facility's current drawn balance, so a facility 90%-hedged on a small drawing escaped the multiplier even with large undrawn headroom. Per Art. 123B(2A),
engine/sa/namespace.py::apply_currency_mismatch_multipliernow rescales the coverage test against the fully-drawn base (max(drawn_amount, facility_limit)) foris_revolvingrows — effective coverage =hedge_coverage_ratio × drawn / full_draw_base— so the waiver correctly fails once measured against the committed limit (e.g. a 0.95-hedged-on-drawing revolving QRRE with £100k drawn / £400k limit → effective 0.2375 < 0.90 → multiplier fires → RW 0.75→1.125). Non-revolving exposures are unchanged; the change reuses the existingB31_CURRENCY_MISMATCH_HEDGE_COVERAGE_FLOOR(no new floor literal) and defaults defensively when the revolving columns are absent (production frames unaffected). Pinned bytests/acceptance/basel31/test_p1_94d_art_123b_2a_revolving_instalment_rule.py(11 tests: revolving in-scope RW 1.125 / RWA £112,500; non-revolving and fully-drawn-revolving controls hold the waiver at RW 0.75). Ref: PRA PS1/26 Art. 123B(2A); BCBS CRE20.88.
Fixed¶
- P1.175 — CRR Art. 114 / Art. 123 citation comments corrected (cosmetic, no calc impact). In-source comments cited "Art. 114(3)/(4)" for the domestic-currency sovereign 0% RW (correct refs: Art. 114(4) UK / Art. 114(7) EU) and "Art. 123(3)(a-b)" for the 35% retail payroll/pension RW (correct ref: Art. 123(4)). 20 citation strings corrected across
engine/sa/namespace.py,engine/classifier.py,engine/irb/guarantee.py,data/tables/eu_sovereign.py,data/schemas.py,data/tables/b31_risk_weights.py; the legitimate Art. 123(3)(c) 100% non-regulatory-retail citation is preserved and no numeric scalar changed. Pinned bytests/contracts/test_crr_art114_citation_paragraph.py+tests/contracts/test_crr_art123_payroll_citation.py. Ref: CRR Art. 114(4)/(7), Art. 123(4). - P6.32 — two inline A-IRB
0.5floor multipliers hoisted out ofengine/ccf.pyinto newdata/tables/airb_floors.py(arch-cleanup, no calc impact). The CRE32.27 own-estimate-CCF floor and the Art. 166D(5)(b) off-balance-sheet EAD floor were bare0.5literals carrying# TODOmarkers; both are nowDecimal("0.5")constants (AIRB_REVOLVING_CCF_FLOOR_MULTIPLIER,AIRB_OBS_FLOOR_B_MULTIPLIER) in a new data-table module, coercedDecimal->floatat the call site via the house pattern.float(Decimal("0.5")) == 0.5, so values are byte-identical (tests/unit/test_ccf.py156 passed unchanged). Pinned bytests/unit/test_p6_32_airb_floors_hoisted.py(AST scan of the two function bodies + constant-value checks). Ref: PRA PS1/26 Art. 166D(5)(b); BCBS CRE32.27. - P6.35 —
apply_ccp_risk_weightQCCP trade-exposure RW citation corrected from CRR Art. 307 to Art. 306(1) (metadata-only; updates the watchfire citation matrix). The 4% client-cleared trade-exposure RW carried a stray@cites("CRR Art. 307")(Art. 307 defines the trade-exposure value, not the RW) stacked beside the correct@cites("CRR Art. 306"); the redundant decorator is removed and the docstring relabelled so both the 2% and 4% RWs cite Art. 306(1). Runtime unchanged (0.02 / 0.04). Pinned bytests/unit/ccr/test_ccp.py::test_apply_ccp_risk_weight_cites_art_306_not_307. Ref: CRR Art. 306(1); PRA PS1/26 Annex R §9. - P6.23 — transitional output-floor reporting dates corrected from mid-year to 1 January (PRA PS1/26 Art. 92(5)).
engine/comparison.py::_TRANSITIONAL_REPORTING_DATESwas hardcoded todate(YYYY, 6, 30)for the transitional-schedule comparison runner, contradicting Art. 92(5), which keys each transitional output-floor rate to 1 January (60% from 1 Jan 2027, 65% from 1 Jan 2028, 70% from 1 Jan 2029) with the 72.5% steady-state applying from 1 Jan 2030 under Art. 92(2A). The four literals are nowdate(YYYY, 1, 1)and two stale "mid-year" docstrings were corrected; the canonicalOutputFloorConfig.basel_3_1()schedule was already on 1-Jan dates, so only the comparison-runner constant was out of step (the mid-year dates happened to resolve to the same percentage within each calendar band, so this surfaced as a wrong reporting-boundary date rather than a wrong floor percentage). Pinned bytests/unit/test_transitional_schedule.py(TestTransitionalDates::test_dates_are_first_of_january, inverted from the prior mid-year assertion, plus a new default-path timelinereporting_datebehavioural test; 21 tests green). Ref: PRA PS1/26 Art. 92(5)/(2A). - P2.26 — COREP "(-)"-labelled deduction columns now reported with the correct negative sign (Annex II §1.3).
reporting/corep/generator.pyemitted positive sums for the C 07.00 / OF 07.00 deduction columns 0030/0035/0050/0060/0070/0080/0090/0130/0140 and the C 08.01/02 memorandum column 0290, all of which COREP Annex II §1.3 declares as "(-)" (negative-signed) values — so PRA DPM validation would reject the return. A_negate_deduction_cols(values, negative_cols)pass keyed on module-level_C07_NEGATIVE_COLS/_C08_NEGATIVE_COLSnow negates those columns at the emit boundary, applied after the reconciliation columns (0040 net-of-adjustments, 0110, 0150, IRB 0090) have consumed the positive magnitudes — so the net-exposure arithmetic is byte-unchanged and non-"(-)" columns stay positive; negative-zero is normalised to 0.0 and nulls preserved (@cites("PS1/26, paragraph 1.3")). Pinned bytests/unit/test_corep.py::TestSignConvention(15 tests, including over-negation invariants that assert col 0040 stays +850), with 17 pre-existing COREP tests updated to the corrected sign (13 direct sign flips + 4 reconciliation re-derivations replaced by direct engine-value assertions). Ref: COREP Annex II §1.3. - P1.130 — aggregator class/approach summaries now reflect post-floor RWA when the output floor binds (PRA PS1/26 Art. 92(2A)).
summary_by_class/summary_by_approach(andpost_crm_detailed/post_crm_summary) onAggregatedResultBundlewere generated inengine/aggregator/aggregator.pyfrom the pre-floorcombinedframe, before the portfolio-level output-floor block reassignscombinedto the floored frame. Becauseengine/aggregator/_summaries.pycomputestotal_rwaas(reporting_ead × reporting_rw).sum()and the floor never recomputesreporting_rw, the reported per-class and per-approach RWA understated the floored regulatory total (Art. 92(2A):TREA = max{U-TREA; x·S-TREA + OF-ADJ}) on every portfolio where the floor bound — e.g. a binding-floor portfolio summarised 109.6M instead of the floored 193.75M. Fix (two files): (a)aggregator.pymovespost_crm_detailed/post_crm_summary/summary_by_class/summary_by_approachgeneration to after the floor block so they build from the flooredcombined(return-arg order unchanged;pre_crm_summaryleft in place); (b)_summaries.pyadds a private_floor_addon_expr(cols, ead_col)that folds the per-rowfloor_impact_rwaadd-on (allocated byreporting_ead / ead_finalshare so guarantee-split rows don't double-count; no-oppl.lit(0.0)when the floor didn't run/bind) into the reporting-pathtotal_rwafor both the by-class and by-approach aggregations. The Art. 62(d) EL/T2-credit-cap carve-out (which intentionally uses un-floored IRB RWA) is left byte-for-byte identical, and the floor-not-binding path is unchanged. Pinned bytests/acceptance/basel31/test_p1_130_summaries_reflect_post_floor.py(6 tests; assertions are relationship-framed — summary totals reconcile tooutput_floor_summary.total_rwa_post_floorandresults.rwa_final— so they are robust to IRB-K drift). Ref: PRA PS1/26 Art. 92(2A); CRR Art. 62(d) (carve-out, unchanged).
[0.2.18] - 2026-05-29¶
Changed¶
- On-balance-sheet netting (CRR Art. 195/219) is now driven solely by a
netting_agreement_reference, not facility hierarchy (breaking input-schema change). Previouslygenerate_netting_collateral(src/rwa_calc/engine/crm/collateral.py) pooled negative-drawn deposits and positive-drawn sibling loans bycoalesce(netting_facility_reference, root_facility_reference, parent_facility_reference)— so netting followed the facility tree and silently crossed counterparties whenever they shared a facility node, while the explicitnetting_facility_referenceoverride never even reached netting in the production loader path (it was dropped by the_coerce_loans_to_unifiedcuratedselect). Netting now keys exclusively on a newnetting_agreement_reference(String, optional) column: a deposit (drawn_amount < 0with a non-null reference) and the loans it offsets net together iff they carry the same reference, across different facilities, roots, or counterparties — reflecting the legal right of set-off, which is defined by the agreement rather than the facility structure. The deposit filter drops the oldhas_netting_agreement == Trueandparent_facility_reference is not nullrequirements; the sibling match is a single equality join onnetting_agreement_reference; the pool is grouped by(netting_agreement_reference, currency)and allocated pro-rata byon_bs_for_ead(Art. 219 drawn-on-drawn scope unchanged — contingents andfacility_undrawnrows remain excluded). The hierarchy negative-balance survival guard (hierarchy.py::_aggregate_loan_drawn_per_facilityand the MOF sub-facility helper) now preserves a negative drawn balance whennetting_agreement_reference IS NOT NULLinstead of whenhas_netting_agreementis set, and the new column is threaded through_coerce_loans_to_unifiedso it survives to the netting stage (closing the latent override bug). Breaking: thehas_netting_agreement(Boolean) andnetting_facility_reference(String) columns are removed fromLOAN_SCHEMA— callers must supplynetting_agreement_referenceinstead; portfolios that previously netted via shared facility/root, or relied on thehas_netting_agreementflag, will stop netting until the participating rows are given a matching reference (this can increase RWA for affected portfolios — intended).@cites("CRR Art. 219")added togenerate_netting_collateral(watchfire coverage tuple updated).processor._join_netting_amountsand COREP C 08.01/02 column 0035 (on_bs_netting_amount) are unchanged. Rewrittentests/unit/crm/test_netting.pypins the new contract — includingtest_cross_counterparty_cross_facility_netting(a deposit for counterparty A under facility FAC_A nets a loan to counterparty B under facility FAC_B sharingAGR1) andtest_only_matching_reference_nets_in_same_facility(same facility, different references ⇒ no netting). Docs updated:docs/data-model/input-schemas.md,docs/architecture/data-flow.md,docs/user-guide/methodology/crm.md. Ref: CRR Art. 195, Art. 219, Art. 223; PRA PS1/26 Art. 195/219.
[0.2.17] - 2026-05-29¶
Added¶
- SA-CCR per-article spec pages (D2.77 / D2.78 / D2.79). Three more pages under
docs/specifications/crr/ccr/close the next set of discoverability gaps against shippedsrc/rwa_calc/engine/ccr/modules:legal-enforceability.mddocuments Art. 272(4) contractual-netting definition, Art. 295 framework for recognising netting (three eligible agreement types + cross-entity-group exclusion), Art. 296(2)(a)–(d) four legal-opinion obligations including the four-jurisdiction sweep, Art. 297 ongoing-monitoring duty plus supervisory power to disregard non-enforceable netting, the engine break-out atsa_ccr.py:83-210(each trade in a non-enforceable set becomes its own one-trade synthetic netting set), and a worked two-ITM / three-OTM example showing higher EAD when netting is non-enforceable;wrong-way-risk.mddocuments Art. 291(1)(a) general WWR and 291(1)(b) specific WWR definitions, the 291(2)–(3) identification and stress-testing process, the 291(5) carve-out plus LGD = 100% override mechanic, the 291(6) senior-management reporting hook, and the SA-CCR ↔ IMM demarcation (Art. 284(9) IMM α re-estimation, Art. 274(2) SA-CCR α = 1.4 unchanged) for general WWR — enginewwr.pydocumented including the unwired-into-pipeline_adapter status;ccp-exposures.mddocuments Art. 306(1)(a) 2% QCCP trade-exposure RW, Art. 306(1)(b)–(c) client-cleared 2% / 4% split with the Art. 305(2)(a)–(c) segregation / portability / operational-requirement condition list, Art. 306(4) RWA aggregation, Art. 307 / 308(3) / 309(2) default-fund-contribution 12.5× multiplier, and the non-QCCP fallback through the standard SA-CCR + SA institution ladder with four worked examples (direct-cleared, client-cleared both branches, non-QCCP). Ref: PRA Rulebook (CRR) Part Art. 272(4), 291, 295–297, 305, 306–309 (Basel 3.1 inherits unchanged per PS1/26 Appendix 1 p. 396, 457). - SA-CCR per-article spec pages (D2.74 / D2.75 / D2.76). Three more pages under
docs/specifications/crr/ccr/close the next set of discoverability gaps against shippedsrc/rwa_calc/engine/ccr/modules:rc-calculation.mddocuments Art. 275 — unmarginedRC = max(V − C, 0), marginedRC = max(V − C, TH + MTA − NICA, 0), the Art. 271(7) NICA composition (independent collateral + segregated IM held − non-segregated IM pledged), and four worked NS_A/B/C/D examples covering the threshold-floor binding behaviour;pfe-multiplier.mddocuments Art. 278 — the asset-class add-on aggregation as a plain linear sum across IR + FX + credit + equity + commodity (fill_null(0.0)for placeholder asset classes), the canonical multipliermin(1, F + (1−F)·exp((V−C)/(2·(1−F)·AddOn)))withF = 0.05, the four-regime behaviour table, and a CCR-A1 cross-check pinned to the live golden values;ead-composition.mddocuments Art. 274(2) —EAD = α·(RC + PFE)with α = 1.4, BCBS CRE52.1 calibration rationale, the Art. 274(2) α = 1 carve-out for non-financial counterparties and pension scheme arrangements, the Art. 274(2A)–(2B) transitional alpha-add-on phase-in for legacy CVA-exempt trades, downstream routing into the SA/IRB exposure ladder viapipeline_adapter.ccr_rows_to_exposures, and the Art. 92(2A) S-TREA consumer for the output floor. Ref: PRA PS1/26 Art. 274, 275, 278, 271(7); BCBS CRE52.1, CRE52.10–11, CRE52.20–23. - SA-CCR per-article spec pages (D2.71 / D2.72 / D2.73). Three new pages under
docs/specifications/crr/ccr/close the discoverability gap between the shippedsrc/rwa_calc/engine/ccr/modules and the spec set:supervisory-delta.mddocuments Art. 279a (linear ±1, Black-Scholes Φ(d1) option delta with the long/short × call/put sign rule, and the Art. 279a(3) CDO-tranche attachment/detachment formula);maturity-factor.mddocuments Art. 279c unmargined√(min(M,1y)/1y)with the 10 BD floor and Art. 285 margined1.5·√(MPOR_eff/250)plus the MPOR cascade (5 BD SFT / 10 BD OTC base, 20 BD large-or-illiquid upgrade, dispute doubling, remargining-frequency adjustment);hedging-sets.mddocuments the Art. 277 per-asset-class partition (IR three maturity buckets per currency, FX per currency pair, credit single name, equity single issuer, commodity five buckets) and the Art. 277a inter-bucket correlation parameters. IR + FX worked examples land now; credit / equity / commodity worked examples placeholder-flagged on engine batch P8.35–P8.38. Thecrr/ccr/index.mdstatus table flipped three rows from "Pending" to "Live". Ref: PRA PS1/26 Art. 277, 277a, 279a, 279c, 285.
Fixed¶
- P8.19 — SA-CCR margined replacement cost (Art. 275(2)) now wired through the pipeline adapter.
compute_rc_marginedwas implemented and unit-tested (P8.11) but never invoked by the orchestrator:pipeline_adapter.py::ccr_rows_to_exposuresonly calledcompute_pfe, which inlined the unmarginedRC = max(V − C, 0)for every netting set — so margined sets that should bind the threshold floormax(V − C, TH + MTA − NICA, 0)were understated, and the synthetic exposure row carried onlyrc_unmargined._derivative_rows_to_exposuresnow callscompute_rc_unmargined+compute_rc_marginedon the netting-set frame andcoalesce(rc_margined, rc_unmargined)into a unifiedrccolumn;compute_pfeconsumes that unifiedrcwhen present (EAD = α·(rc + PFE)) and falls back torc_unmarginedotherwise, keeping the lazy plan single-pass and the call backward-compatible.rc_marginedandrcare surfaced on the synthetic row for the COREP-reconciliation surface. Worked golden (CCR-A13): a margined institution NS withV = −4,000,000,TH = 2,000,000,MTA = 500,000,NICA = 250,000now takesrc_margined = 2,250,000(TH + MTA − NICA arm) →EAD = 6,464,360.39/RWA = 3,232,180.20at CQS-2 institution 50%, vs the buggyrc = 0/RWA = 1,657,180.20. Margined maturity factor (P8.14) remains out of scope. Pinned bytests/acceptance/ccr/test_ccr_a13_margined_rc.py(6 tests). Ref: PRA PS1/26 Art. 275(2); CRR Art. 275(2); Art. 285. - P1.139 — Basel 3.1 CIU equity transitional Rule 4.7-4.8 higher-of now applied to look-through / mandate-based equity underlyings (PRA PS1/26 Rules 4.7-4.8; CRR Art. 155(2)).
_apply_transitional_floorexcludes CIU look-through / mandate-based exposures from the equity transitional floor (correct for the wrapper, which is not itself floored per the Rule 4.7 derogation to Art. 132A) — but the exclusion also meant the underlyings never received the Rule 4.8 higher-of, and an EQUITY-class underlying with no CQS fell back to the 100% CQS-miss default (_DEFAULT_HOLDING_RW), understating capital for transitional years 2027-2029. Fix lands inengine/equity/calculator.py::_resolve_look_through_rw(not_apply_transitional_floor, which is unchanged): an EQUITY-class look-through / mandate underlying whose CQS join misses now takesmax(legacy Art. 155(2) "other equity" simple RW = 370%, Rule 4.2/4.3 transitional SA RW)whenever the B3.1 equity-transitional regime is active — gated on the existingequity_transitional.enabled+reporting_datesurface (no new config fields;equity_transitional.enabledis the IRB-permission proxy since the regime only applies to firms that held IRB equity permission on 31 Dec 2026). New helper_equity_holding_higher_of_rwreusesIRB_SIMPLE_EQUITY_RISK_WEIGHTS(no new engine-scope scalar). Worked golden: a £1m look-through CIU with a £600k EQUITY underlying (→ max(370%, 160%)=370%) and a £400k CORPORATE CQS-3 underlying (→ B3.1 75%) re-aggregates tociu_look_through_rw = 2.52,RWA = 2,520,000vs the buggy 900,000; the wrapper stays unfloored. Mandate-based 1.2× variant, the Rule 4.9-4.10 opt-out election (P2.15), and the no-IRB-permission negative control remain out of scope. Pinned bytests/acceptance/basel31/test_p1_139_ciu_transitional_higher_of.py(3 tests). Ref: PRA PS1/26 Rules 4.7-4.8; CRR Art. 155(2). - P1.142 — Art. 124E three-property limit now auto-derives income-dependent RE routing for natural persons (PRA PS1/26 Art. 124E(2)). A natural-person obligor collateralised by more than three qualifying residential properties is "materially dependent on cash flows generated by the property" and must take Art. 124G income-producing risk weights — but the engine had no derivation, so a 4+-property buy-to-let obligor was silently risk-weighted on the Art. 124F loan-split track unless the caller manually set the income flag, understating capital. New
qualifying_property_count: ColumnSpec(pl.Int32, required=False)onCOUNTERPARTY_SCHEMA(data/schemas.py) andB31_RRE_THREE_PROPERTY_LIMIT = 3indata/tables/b31_risk_weights.py;engine/classifier.pyderivesmaterially_dependent = cp_is_natural_person AND (cp_qualifying_property_count > 3)(strict>3) withcoalesceprecedence so an explicit caller-supplied income flag still wins, Basel-3.1-only guard (CRR routing untouched). The derived flag overrideshas_income_cover, routing the breach obligor onto Art. 124G (70-80% LTV → RW 0.50, RWA 100,000) while a 3-property control obligor stays on the Art. 124F loan-split (RW 0.34667, RWA 69,333.33). Cross-lender aggregation and housing-unit counting (Art. 124E(4)) remain the caller's responsibility (out of scope). Pinned bytests/acceptance/basel31/test_p1_142_three_property_income_dependent.py(6 tests, incl. the strict->3boundary). Ref: PRA PS1/26 Art. 124E(2), Art. 124G. - P8.38 — SA-CCR SFT EAD now routes through CRR Art. 271(2) + Art. 220-223 FCCM branch. Previously, booking an SFT (
transaction_type="sft") through thetradestable picked up the derivativeα·(RC+PFE)path inengine/ccr/sa_ccr.py::compute_eadand produced an EAD on the order of £4M from the small per-asset-class supervisory-factor add-on — instead of the regulatorily correctE* = max(0, E·(1+HE) − CVA·(1−HC−HFX))of ~£65M for a £60.7M uncollateralised IG ≥5y debt-security SFT (operator-surfaced via empirical mis-pricing). Newsrc/rwa_calc/engine/ccr/sft_fccm.pyimplements the FCCM branch reusing the supervisory haircut table (data/tables/haircuts.py) at the 5-business-day SFT liquidation period per Art. 224(2)(c) and Art. 226(2) scaling — no new regulatory scalars.pipeline_adapter.py::ccr_rows_to_exposuresnow partitionsRawCCRBundleontrades.transaction_typeand concatenates derivative and SFT outputs viadiagonal_relaxed. Synthetic SFT exposure rows emitrisk_type="CCR_SFT"(placeholder atdomain/enums.py:452promoted to live) andccr_method="fccm_sft"(new third literal alongside"sa_ccr"); SA-CCR-only columns (rc_unmargined,pfe_addon,addon_aggregate,pfe_multiplier) are null on FCCM rows. NewCCRConfig.sft_method: Literal["fccm","var","imm"] = "fccm"field; VaR (Art. 221) and IMM (Art. 283) deferred. Pinned by 18-test acceptance pairtests/acceptance/ccr/test_ccr_a11_a12_sft_fccm_ead.py— load-bearing anti-degenerateA11.ead_ccr > 60_000_000catches the original bug; A11 (uncollateralised) lands at EAD £64,133,710.53 / RWA £32,066,855.26 at institution CQS 2 = 50%; A12 (cash-collateralised £60M) lands at E* £4,133,710.53 / RWA £2,066,855.26. Mixed SFT+derivative netting sets (Art. 271 split) remain out of scope (deferred — both A11 and A12 are 100% SFT NSes). PS1/26 numerically identical for this path. Ref: CRR Art. 271(2), Art. 220(1)(a), Art. 220(3)(a)(i), Art. 223(5), Art. 224 Table 1, Art. 224(2)(c), Art. 226(2), Art. 120 Table 3; PRA PS1/26 CCR (CRR) Part Art. 271/220-223; BCBS CRE22.40-58, CRE52.16-17. - P2.19 — Basel 3.1 SA equity higher-risk (400%) test generalised beyond pre-classified PE/VC (PRA PS1/26 Art. 133(4)). The 400% higher-risk gate in
engine/equity/calculator.py::_apply_b31_equity_weights_saonly fired forequity_type ∈ {private_equity, private_equity_diversified}, so anunlistedequity with business existing < 5 years (andis_speculative=False) wrongly received the standard 250% (Art. 133(3)) instead of 400% (Art. 133(4)). Generalised the gate to any non-subordinated / non-CIU / non-central-bank / non-government-supported equity that is unlisted (~is_exchange_traded) with evidenced business age < 5y; null/unknown age still resolves to 250% for non-PE types (only PE/VC retains the conservative null = young routing), preserving 18 pre-existing scenarios. Reuses the existing 4.00 scalar (B31_SA_EQUITY_RISK_WEIGHTS[PRIVATE_EQUITY]) — no schema change. Scope correction: the originating plan bullet proposedis_held_for_short_term_resale/is_derived_from_derivativeflags; those are BCBS CRE60.20 criteria, not PRA (already corrected in-repo as D1.38/D3.37) and were deliberately NOT added. Pinned bytests/acceptance/basel31/test_p2_19_unlisted_young_equity_higher_risk.py. Ref: PRA PS1/26 Art. 133(3)/(4), Glossary p.5 (higher-risk equity = unlisted AND business < 5y). - P2.33 — UK residential-mortgage commitment now receives the 50% CCF (PRA PS1/26 Art. 111 Table A1 Row 4(b)). Absent a flag, a UK residential-mortgage commitment fell through to the 40% "other commitment" (OC) CCF, understating EAD. New
is_uk_residential_mortgage_commitmentBoolean (FACILITY/CONTINGENTS schema, defaultFalse) is threaded throughHierarchyResolverto the CCF stage, whereengine/ccf.py::_compute_ccfapplies a Basel-3.1-gated 50% override (reusingSA_CCF_B31["MR"]— no new scalar) bounded by the Row 4(b) "not subject to a conversion factor of 10% or 100%" carve-out. CRR is a no-op and the unflagged OC path stays 40%. Pinned bytests/acceptance/basel31/test_p2_33_uk_resi_mortgage_commitment_ccf.py. Ref: PRA PS1/26 Art. 111(1) Table A1 Row 4(b). - P2.44 — inferred ECAI ratings now disapplied for SA specialised-lending routing (PRA PS1/26 Art. 139(2B)). An IRB firm routing SA SL through Art. 122B(1) must use only directly-applicable (issue-specific) ECAI assessments, not Art. 139(2)/(2A) inferred / issuer-level fallbacks; the engine carried a single
external_cqswith no provenance, so an SL exposure whose only rating was inferred picked up the rated-corporate CQS table (e.g. CQS 3 → 75%) instead of the unrated object-finance 100%. Newrating_is_issue_specific/rating_is_inferredBooleans (RATINGS_SCHEMA) andexternal_rating_is_issue_specific(HIERARCHY_OUTPUT_SCHEMA) are threaded through the rating-inheritance chain;engine/sa/namespace.py::_prepare_risk_weight_lookupnulls the CQS for SL exposures (Art. 122B(1)) when the resolved rating is not issue-specific, re-routing to the unrated object-finance 100% RW (reusingB31_SA_SL_RISK_WEIGHTS["object_finance"]). Scoped to SL only and Basel-3.1-gated; schema defaults (issue_specific=True) preserve all existing rated-SL behaviour. Pinned bytests/acceptance/basel31/test_p2_44_sa_sl_inferred_rating_disapplied.py. Ref: PRA PS1/26 Art. 122B(1), 139(2)/(2A)/(2B).
Changed¶
- P6.30 — removed the dead
compute_pfe_ir_singletonstub from the CCR engine. The single-trade IR PFE singleton atengine/ccr/pfe.pystill raisedNotImplementedError("…full PFE per Art. 278 is P8.16")even though P8.16 (v0.2.11) shipped the productioncompute_pfein the same module — a caller hitting the dead namespace methodlf.ccr.pfe_ir_singleton()would have got a misleading not-implemented error. Deleted the function (and its@cites("CRR Art. 278")), thepfe_ir_singletonnamespace shim + docstring bullet (engine/ccr/namespace.py), and the import +__all__entry (engine/ccr/__init__.py), and corrected the stale module scaffold comment that claimed the SA-CCR formula bodies were still stubbed. Watchfire coverage is preserved: productioncompute_pferetains its own@cites("CRR Art. 278"). The pre-existing contract test was inverted from assertingNotImplementedErrorto asserting the symbol is no longer importable. Pinned bytests/contracts/test_ccr_engine_scaffold.py::test_compute_pfe_ir_singleton_removed.
[0.2.16] - 2026-05-27¶
Fixed¶
- P1.190 — Basel 3.1 F-IRB Foundation Collateral Method (Art. 230) now uses the PS1/26 continuous LGD* formula instead of CRR's step function. The engine was inheriting three CRR mechanics that PS1/26 explicitly removes for non-financial collateral: (a) the 30% C* minimum-coverage threshold (
engine/crm/collateral.py:858-887zero-out of_eff_re_a/_eff_op_awhen raw collateral < 30% of EAD), (b) the 1.4× / 1.25× overcollateralisation divisor (engine/crm/expressions.py::overcollateralisation_ratio_expr()), and (c) the immovable-property haircut left at 0.00 inBASEL31_COLLATERAL_HAIRCUTS["real_estate"](data/tables/haircuts.py:134) with a misleading "Handled via LTV" comment that confused the SA Art. 124A-L loan-splitting path with the F-IRB FCM HC term. PS1/26 Art. 230(1) is a continuous formulaLGD* = LGDU·(EU/(E·(1+HE))) + LGDS·(ES/(E·(1+HE)))whereES = C·(1−HC−Hfx); Art. 230(2) tabulates HC = 40% for immovable property, receivables, and other physical collateral; there is no C* threshold and no OC divisor. Bug (a) fix wraps the C* block inif not is_basel_3_1:; bug (b) fix extendsovercollateralisation_ratio_expr()with anis_basel_3_1parameter returning 1.0 for non-financial under B3.1; bug (c) fix sets the RE haircut toDecimal("0.40")and rewrites the comment to distinguish the SA LTV path from the F-IRB FCM HC term. Follow-on engine fix inengine/crm/haircuts.pygates Art. 226 liquidation-period scaling onNON_FINANCIAL_COLLATERAL_TYPES— Art. 230 HC values are credit-quality multipliers, not volatility adjustments, so thesqrt(T_m/10)scaling that would have inflated 0.40 to ~0.566 at the 20-day default is now correctly skipped. Capital impact is bidirectional: RWA decreases for B3.1 F-IRB exposures with thin non-financial collateral previously zeroed by the spurious C* gate; RWA changes magnitude on collateralised slices previously divided by 1.4 because B3.1's(1−0.40)=0.60HC term pairs with a much lower LGDS (20% vs CRR's 35% senior). Pinned by 20 new tests: 4 table-level haircut pins (including CRR/B31 regression guards) + 4 B3.1 acceptance hand-calcs (b31_thin_reLGD*=0.397,b31_full_re0.280,b31_other_physical0.315,b31_re_threshold_30pct0.364) + 2 CRR regression mirrors (crr_thin_re0.450 unsecured fallback,crr_full_re0.378571 via 1.4× divisor). 10 pre-existing unit tests intest_collateral_sequential_fill.pyandtest_art169_lgd_modelling.pywhose hand-calcs assumed the old buggy B3.1 step-function behaviour updated to the correct continuous-formula values. Ref: PRA PS1/26 Art. 230(1)-(2); BCBS CRE32.27-32.35; verbatim text atdocs/assets/ps126app1.pdfp.209-210. - SME supporting factor E* now aggregated across all approach branches (CRR Art. 501): the windowed sum producing the per-row tier threshold input (
total_cp_drawn) was previously computed inside each approach branch — SA, IRB, and slotting each calledSupportingFactorCalculator.apply_factorson its own filtered LazyFrame after the orchestrator split atengine/pipeline.py:813, so the.sum().over("_sme_group_key")window function only ever saw the rows in its own branch. A lending group containing an SA-treated SME with slotting or IRB siblings therefore had its E* understated; more of the SME's drawn fell into tier 1 (0.7619) and the blended supporting factor was artificially low — bank received a larger Art. 501 discount than the regulation permits. Fix moves the aggregation to the unified frame: new module-level helpercompute_e_star_group_drawninsrc/rwa_calc/engine/sa/supporting_factors.pymirrors the existing per-row Art. 501 logic (drawn_amount + interestclipped at zero, minusmin(residential_collateral_value, drawn), summed overlending_group_referencewith fallback tocounterparty_reference) and writes the result to a stablee_star_group_drawncolumn on every row.PipelineOrchestrator._run_calculators_split_oncenow calls the helper once atengine/pipeline.py:809, immediately aftermaterialise_barrier(..., "pipeline_pre_branch")and before the SA/IRB/slotting split, so all approach rows contribute.SupportingFactorCalculator.apply_factors(lines ~273-320) now readse_star_group_drawnwhen present and aliases it to the existing output columntotal_cp_drawn— downstream consumers and the result schema are unchanged. The legacy per-branch window-sum path remains as a fallback whene_star_group_drawnis absent so existing unit tests that build minimal LazyFrames and callapply_factorsdirectly continue to work byte-identically. Helper is config-gated (config.supporting_factors.enabled→ no-op under Basel 3.1 so the column is not added). Pinned by 8 new tests intests/unit/test_supporting_factors_cross_approach.py— load-bearing assertion istest_blended_factor_uses_pre_computed_group_total: an SA SME with drawn £1m and a slotting sibling with drawn £5m now seestotal_cp_drawn = £6m(not £1m) andsupporting_factor ≈ 0.8178(not 0.7619, derived from config to handle the actual GBP threshold of £2,183,000). Full suite green: 6804 passed, 2 skipped. No regulatory scalars introduced — formula and tier factors unchanged. Ref: CRR Art. 501(2)(a) ("total amount owed to the institution … by the obligor client or group of connected clients"); BCBS does not impose a directly equivalent supporting factor under Basel 3.1. - On-balance-sheet netting now restricted to drawn loan siblings only (CRR Art. 219):
generate_netting_collateralinsrc/rwa_calc/engine/crm/collateral.pywas allocating synthetic cash collateral pro-rata across every positive-EAD sibling under the same netting facility — including off-balance-sheet contingents and syntheticfacility_undrawnrows (the per-facility undrawn-headroom rows emitted byhierarchy.py). CRR Art. 219 explicitly limits OBS netting to drawn loans/deposits ("loans to and deposits with the lending institution"); the bank holds no irrevocable lending commitment on an off-BS row that a deposit could net against. Two coupled fixes: (a) thepositive_siblingsfilter now requiresexposure_type == "loan"ANDon_bs_for_ead > 0, excluding contingents and facility_undrawn; (b) pro-rata basis switched fromead_for_crm(=on_bs_for_ead + nominal_after_provision, the CCF=100% override per Art. 223(4)) toon_bs_for_ead(the drawn portion), so a partly-drawn loan with a large undrawn nominal no longer captures an inflated share of the netting pool. The Art. 223(4) override remains the basis for FCCM E netting against external cash collateral — it is a collateral-valuation* rule, not an OBS-netting allocation rule. Source filter (negative-drawn loans withhas_netting_agreement=True) was already correctly drawn-only and unchanged. Downstream collateral pipeline behaviour preserved: synthetic row still flaggedbeneficiary_type="loan"withbeneficiary_reference=exposure_reference, lands at thedirectallocation level and does not re-spread across facility/counterparty pools. Pinned by 4 new tests intests/unit/crm/test_netting.py::TestNettingDrawnOnlyScope— contingent-excluded, facility_undrawn-excluded, pro-rata-uses-drawn-not-ead_for_crm (load-bearing anti-degenerate: deposit 200 with LOAN_A drawn=400 / no off-BS and LOAN_B drawn=100 / off-BS=900; old code split 57.14 / 142.86 byead_for_crm, new code correctly splits 160 / 40 by drawn portion), and mixed-facility e2e. All 23 netting tests + 5,192 unit + 1,633 acceptance/integration/contract tests pass. Graceful fallback added for direct unit-test callers of_generate_netting_collateralthat omitexposure_type/on_bs_for_ead(production always supplies them via the hierarchy +_compute_eadupstream). Docs updated:docs/user-guide/methodology/crm.mdOn-Balance Sheet Netting section now states drawn-only scope andon_bs_for_eadpro-rata basis with a mixed-facility example;docs/specifications/crr/credit-risk-mitigation.mdadds an Art. 219 callout note distinguishing OBS-netting allocation basis from the Art. 223(4) collateral-valuation override. COREP C07 column 0035 (on_bs_netting_amount) semantics unchanged. Ref: CRR Art. 195, 205, 206, 219, 223(4), 224, 230; PRA PS1/26 Art. 219 (unchanged); BCBS CRE22.68-69.
Changed¶
- Supporting factors module relocated from
engine/sa/toengine/top-level (cross-approach stage module):src/rwa_calc/engine/sa/supporting_factors.py→src/rwa_calc/engine/supporting_factors.py. The file housed bothSupportingFactorCalculator(called by all three approach branches — SA atengine/sa/namespace.py:2104, IRB atengine/irb/calculator.py:288, slotting atengine/slotting/calculator.py:166) and the module-level helpercompute_e_star_group_drawn(called by the pipeline orchestrator atengine/pipeline.py:810on the unified post-CRM frame before the SA/IRB/slotting split). Filing it underengine/sa/was a false-locality signal —engine/irb/andengine/slotting/both had to import fromengine/sa/, implying SA was a dependency of the other approach branches when in fact supporting factors are cross-cutting. The module now sits as a peer ofengine/ccf.py,engine/hierarchy.py,engine/classifier.py,engine/re_splitter.py, andengine/materialise.py— the other cross-cutting stage modules at the same level. Pure relocation: no behavioural change, no regulatory logic touched, no schema or output changes. Move performed viagit mvto preserve history. Import paths updated in 5 source files (engine/pipeline.py,engine/sa/namespace.py,engine/irb/calculator.py,engine/slotting/calculator.py,engine/sa/__init__.py) and 4 test files (tests/unit/test_supporting_factors.py,tests/unit/test_supporting_factors_cross_approach.py,tests/unit/crr/test_crr_sa.py,tests/contracts/test_watchfire_coverage.py). The dead re-export inengine/sa/__init__.py(SupportingFactorCalculator,create_supporting_factor_calculator) was dropped since every caller already imported from the full module path.LOGGER_REQUIRED_EXEMPTinscripts/arch_check.pyand the--8<--snippet path indocs/user-guide/methodology/standardised-approach.mdupdated to the new location.docs/api/engine.md,docs/user-guide/methodology/supporting-factors.md,docs/data-model/output-schemas.md, anddocs/specifications/common/default-definition.mdreferences updated;docs/development/citation-matrix.mdregenerated viascripts/generate_citation_matrix.py. Historical changelog entries that mention the old path are intentionally left as-is. Full dev-loop suite green: 6804 passed, 2 skipped. Ref: CRR Art. 501 (SME), Art. 501a (infrastructure) — both regulations are themselves approach-agnostic, matching the new module location.
[0.2.15] - 2026-05-26¶
Changed¶
scripts/deploy.pynow promotes[Unreleased]bullets into the new version section (was: dropped them silently): the previousupdate_changeloglooked for the exact placeholder shape## [Unreleased]\n\n### Added\n- (Next release changes will go here)\n\n### Changed\n- (Next release changes will go here)\n\n---and fell through to a fallback that inserted a fresh## [version]block with a hardcodedVersion bump for PyPI releasebullet above the existing[Unreleased]. Because the team's actual workflow keeps[Unreleased]populated with real bullets, the exact-match branch never fired and every release lost its accumulated changelog content. Promotion logic extracted intoscripts/_deploy_changelog.py(pure string transformspromote_unreleasedandupdate_version_tablefor testability) anddeploy.pynow delegates. The new helper: (a) extracts the[Unreleased]body up to and including its trailing---, (b) parses subsections (### Added,### Changed,### Fixed, etc.) preserving insertion order, (c) drops bullets matching the literal placeholder line, (d) writes the surviving bullets under## [{new_version}] - {today}, and (e) resets[Unreleased]to the canonical empty placeholder. Placeholder-only or missing[Unreleased]blocks fall back to the originalVersion bump for PyPI releasestub. Re-running with## [{new_version}]already present is a no-op. Pinned by 7 new tests intests/unit/test_deploy_changelog.py(placeholder-only fallback, real-bullet promotion, mixed placeholder/real, re-run idempotency, subsection-order preservation, plus 2 version-table cases). New/releaseslash command at.claude/commands/release.mdpreviews what will be promoted, confirms with the operator, and invokesscripts/deploy.py.- SME classification now falls back to
total_assetswhenannual_revenueis null (CRR Art. 4(1)(128D) / Commission Recommendation 2003/361/EC Art. 2): thetotal_assetscolumn onCOUNTERPARTY_SCHEMAwas previously projected onto exposures but read only by the equity calculator; every SME-classification gate keyed offcp_annual_revenuealone, so a counterparty with null turnover and a small balance sheet was silently treated as a large corporate. The classifier's_add_counterparty_attributesnow derives a single shared metricsme_size_metric_gbp = coalesce(cp_annual_revenue, cp_total_assets)plus a provenance columnsme_size_source ∈ {"turnover", "assets", null}, and a new helper_is_sme_by_size_exprcompares the metric to the appropriate threshold per source. The six SME gates (is_corporate_sme,is_retail_sme,is_sl_sme,_reclassify_corporate_to_retail, Art. 123A retail auto-qualification, and the inverse Art. 147A(1)(d) large-corp F-IRB restriction) all read the new helper. The IRB Art. 153(4) third-subparagraph substitution is realised by sourcingturnover_mfrom the coalesced metric inengine/irb/namespace.py(gated on the classifier'sis_smeflag to avoid double-counting counterparties in the EUR 43m-50m equivalent band) — so the SME correlation reduction now picks up assets asSwhen annual sales are not a meaningful indicator. Art. 501(2)(c) is preserved exactly: the SA supporting factor predicate inengine/sa/supporting_factors.pywas tightened fromis_smetois_sme & cp_annual_revenue.is_not_null() & cp_annual_revenue > 0, so a counterparty identified as SME via assets receives theCORPORATE_SMEexposure class and the IRB correlation benefit butsupporting_factor = 1.0(no Art. 501 capital relief). NewRegulatoryThresholds.sme_balance_sheet_thresholdfield, derived under both CRR and Basel 3.1 from_CRR_SME_BALANCE_SHEET_EUR = Decimal("43000000")at the configured EUR/GBP rate (PS1/26 does not restate the assets threshold in GBP). CLS008 refined: the conservative-large-corp warning now fires only whenannual_revenueis null ANDtotal_assetsis either null or ≥ the SME balance-sheet threshold — populated assets below the threshold resolve the size question definitively and suppress the warning. Pinned by 18 new unit tests intests/unit/classifier/test_sme_assets_fallback.pycovering the four observable behaviours (SME-by-assets, large-by-assets, double-null, turnover-only regression) plus a new self-contained fixture module attests/fixtures/sme_assets_fallback/. The pre-existing P1.126total_assetsfiller value was updated toNoneto preserve the original CLS008 scenario (null revenue with no fallback signal). Full unit (5133) + contract (213) + integration (430) + acceptance (914) suites green. Ref: CRR Art. 4(1)(128D), Art. 153(4) third subparagraph, Art. 501(2)(c); PRA PS1/26 Art. 147A(1)(d), Art. 153(4); Commission Recommendation 2003/361/EC Art. 2.
Added¶
- SA-CCR CCR-A10 mixed-asset-class netting set + per-asset-class add-on Struct on synthetic exposure row (P8.41 CCR-A10, batch 20260526-ccr3): closes the P8.41 CCR-A sub-batch with the load-bearing end-to-end regression for cross-asset-class aggregation per CRR Art. 278(2). The scenario stitches one IR swap + one FX forward + one single-name IG credit CDS + one single-name equity TRS + one OIL_GAS commodity forward into a single legally-enforceable netting set (NS_MIX_001 against institution CP_001 at CQS 2 ⇒ 50% RW per CRR Art. 120(1) Table 3). Trade parameters are exact clones of CCR-A1/A2/A3/A5/A7 so per-class add-ons reproduce existing goldens byte-identically — the only novel quantity is the cross-class aggregation step itself:
AddOn_aggregate = 3,914,298.228 (IR) + 3,198,904.672 (FX) + 2,016,405.972 (credit) + 15,994,523.295 (equity) + 180,000 (commodity) = 25,304,132.167 GBP(pure linear sum per Art. 278(2) / BCBS CRE52.20-22, no cross-class supervisory correlation). With RC=0 (V=C=0) and the Art. 278(3) multiplier at its ceiling of 1.0, this givesEAD = α × (RC + PFE) = 1.4 × 25,304,132.167 = 35,425,785.034 GBPper Art. 274(2) andRWA = 0.5 × EAD = 17,712,892.517 GBP. The load-bearing anti-degenerate* pins24M < addon_aggregate < 26M— a naive sqrt-of-sum-of-squares would have yielded ≈ 16.69M (≈ 8.6M understatement, a regulatorily catastrophic regression mode). Engine change is minimal:src/rwa_calc/engine/ccr/pipeline_adapter.py::ccr_rows_to_exposuresnow surfaces a newaddon_by_asset_class: Struct{interest_rate: Float64, fx: Float64, credit: Float64, equity: Float64, commodity: Float64}column on the synthetic CCR exposure row, pivoting the already-computedaddon_per_classintermediate (no new collects; LazyFrame-first preserved). Missing asset classesfill_null(0.0)so the Struct schema is stable across every netting set regardless of which asset classes it touches, and the five fields are guaranteed to sum toaddon_aggregate(internal-consistency property pinned by the test). This is a pure observability extension — the underlying add-on values are unchanged; the Struct is the audit-trail breakdown needed for downstream COREP C 34.02 (SA-CCR template) and Pillar III CCR3 disclosures. Pinned by 17 tests intests/acceptance/ccr/test_ccr_a10_mixed_asset_class.py(8 numeric pipeline pins + 1 Struct-existence guard + 5 per-class component assertions + 1 Σ-equals-aggregate consistency check + 2 framework / approach assertions). New fixture attests/fixtures/ccr/golden_ccr_a10.pyand expected outputs attests/expected_outputs/ccr/CCR-A10.json. All 86 CCR acceptance tests + 451 contract tests remain green. No new regulatory scalars introduced. Ref: CRR Art. 274(2), 275(1), 277(1)-(3), 277a, 278(1)-(3), 279a/b/c, 280-280c, 295, 120(1) Table 3; BCBS CRE52.20-22 (cross-asset-class linear sum), CRE52.41-69; PRA Rulebook CCR (CRR) Part (SA-CCR cross-class aggregation numerically identical to CRR). - SA-CCR all-asset-class EAD: credit, equity, commodity asset classes shipped (batch 20260526-ccr2; P8.35/P8.36/P8.37): closes the gap that was the originating concern of the SA-CCR scope expansion —
EAD = α × (RC + PFE)is now computed for all five SA-CCR asset classes (interest rate, FX, credit, equity, commodity), not just IR + FX. Three parallel worktree streams landed under a single batch: P8.35 added the credit branch (compute_adjusted_notional_creditper CRR Art. 279b(1)(a) shared-IR supervisory-duration kernel;_compute_addon_creditper Art. 277(2)(c) + 277a + 280a per-entity correlation withSF_SN_IG=0.0046 / SF_SN_HY=0.013 / SF_SN_NR=0.06 / SF_IDX_IG=0.0038 / SF_IDX_HY=0.0106and ρ=0.50 SN / 0.80 IDX). P8.36 added the equity branch (compute_adjusted_notional_equityper Art. 279b(1)(c)d = abs(market_price × number_of_units);_compute_addon_equityper Art. 277(2)(d) + 277a + 280b withSF_EQ_SN=0.32 / SF_EQ_IDX=0.20and ρ=0.50 SN / 0.80 IDX, SN/IDX sub-classes summed within one HS). P8.37 added the commodity branch (compute_adjusted_notional_commodityper Art. 279b(1)(c);_compute_addon_commodityper Art. 277(3)(b) 5-bucket partition — ELECTRICITY=0.40 / OIL_GAS/METALS/AGRICULTURAL/OTHER=0.18 — with within-bucket ρ=0.40 per Art. 280c and no cross-bucket correlation per CRE52.69,AddOn_commodity = sqrt(Σ_b AddOn_b²)). P8.35 also extended TRADE_SCHEMA with a required-Falsecredit_qualitycolumn ({IG, HY, NON_RATED}) — the CDS reference entity is the underlying, not the counterparty, so the supervisory-factor band cannot be derived fromexternal_cqsand must be supplied explicitly (precedent: P8.33'scommodity_type). All five branches incompute_addon_per_asset_classnow dispatch correctly;compute_adjusted_notional_*calls inpipeline_adapter.py::ccr_rows_to_exposureschain IR → FX → credit → equity → commodity coalesce-safely. Defensive null-column injection added to credit / equity / commodity branches so IR/FX-only test frames continue to work without schema-update pressure. Pinned by 3 new acceptance scenarios (CCR-A3single-name IG CDS,CCR-A5single-name equity TRS,CCR-A7oil forward,CCR-A8electricity swap), 6 new unit-test modules attests/unit/ccr/test_adjusted_notional_{credit,equity,commodity}.pyandtest_pfe_{credit,equity,commodity}_addon.py(52 new unit tests in total — including the load-bearing electricity-distinct-from-18%-catch-all anti-degenerate attest_ccr_a8_commodity_electricity_swap::test_electricity_sf_is_distinct_from_other_bucketsand the two-entity credit correlation anti-degenerate attest_pfe_credit_addon::test_credit_addon_two_entities_same_hs_uses_correlation). The flippedtest_credit_asset_class_row_emits_non_null_addonintests/unit/ccr/test_pfe_fx_addon.py:288(previously asserted null while credit was deferred) and the P8.33 contract test's_P8_35_EXPECTED_COLUMN_COUNT = 29(lifted from 28 to absorbcredit_quality) close the contract surface. Existing IR / FX / CCR-A1 / CCR-A2 acceptance tests continue to pass byte-identically — no regression. Full dev-loop suite green: 6703 passed, 23 skipped. No new regulatory scalars introduced (all SF and ρ already indata/tables/sa_ccr_factors.pyfrom P8.7). Ref: CRR Art. 274(2), 275(1), 277(2)(c)-(d), 277(3)(b), 277a, 278, 279b(1)(a)/(c), 280, 280a, 280b, 280c; BCBS CRE52.41-48, CRE52.60-69; PRA PS1/26 CCR (CRR) Part (numerically identical for all five asset classes). - SA-CCR hedging-set partition extension to credit / equity / commodity (P8.34): extends
engine/ccr/hedging_sets.py::assign_hedging_setto emit non-nullhedging_set_idfor the three asset classes that previously fell through to null. Credit derivatives →"CR-{netting_set_id}"per CRR Art. 277(2)(c) (one hedging set per asset class per netting set; single-name vs index discrimination is deferred to the aggregation step in Art. 277a + 280a/b, P8.35's job). Equity derivatives →"EQ-{netting_set_id}"per Art. 277(2)(d) (same single-set-per-NS pattern). Commodity derivatives →"CO-{netting_set_id}-{commodity_type}"per Art. 277(3)(b), a 5-bucket partition keyed on the upper-casecommodity_typecolumn shipped under P8.33 (ELECTRICITY/OIL_GAS/METALS/AGRICULTURAL/OTHER); no cross-bucket netting per CRE52.67. The"CO-"prefix uses the canonicalASSET_CLASS_SHORT_CODE["commodity"]value fromdata/schemas.py:952(the plan bullet erroneously said"CM-"; the schema constant is the SSoT). Nullcommodity_typeon a commodity row → nullhedging_set_id(no fallback string, no error — matches the IR-no-bucket precedent for malformed inputs). A defensivecommodity_typecolumn injection was added so pre-P8.33 test frames (existing FX add-on tests attests/unit/ccr/test_pfe_fx_addon.py) continue to work without requiring fixture updates.reference_entityandis_indexcolumns are deliberately not consumed at the partition step — single-name vs index discrimination flows through the aggregation step where supervisory factor + correlation differ. Corrects the overstated P8.15 closing claim that this work had already shipped. Pinned by 10 new tests intests/unit/ccr/test_hedging_sets_extension.py: 4 prefix-format assertions (credit-SN, credit-idx-shares-HS-with-SN, equity, equity-idx-shares-HS-with-SN), 3 commodity assertions (5-bucket-distinct, same-bucket-collapses, null-bucket-null-id), 2 IR/FX regression guards, and 1 mixed-portfolion_unique == 10across 12 rows spanning all five asset classes. Pre-existing P8.15 IR partition tests + P8.19 FX add-on tests all remain green. Unblocks P8.35 (credit add-on), P8.36 (equity add-on), P8.37 (commodity add-on). Ref: CRR Art. 277(2)(c)-(d), 277(3)(b), 277a; BCBS CRE52.60, CRE52.65, CRE52.67-69; PRA Rulebook CCR (CRR) Part (numerically identical). - SA-CCR TRADE_SCHEMA extension for credit / equity / commodity asset classes (P8.33): adds five nullable columns to
TRADE_SCHEMAinsrc/rwa_calc/data/schemas.py—market_price: Float64andnumber_of_units: Float64(the two factors ofd = market_price × number_of_unitsper CRR Art. 279b(1)(c) for equity and commodity adjusted notional),reference_entity: String(single-name issuer LEI or index ticker — keys the credit hedging set per Art. 277(2)(c) and the equity hedging set per Art. 277(2)(d)),commodity_type: String(one of the five buckets{ELECTRICITY, OIL_GAS, METALS, AGRICULTURAL, OTHER}per Art. 277(3)(b) — keyed to upper-case to match the existingSA_CCR_SUPERVISORY_FACTORS_COMMODITYtable atdata/tables/sa_ccr_factors.py:60-66), andis_index: Boolean(single-name vs index discriminator for the credit / equity supervisory-correlation lookup at the per-asset-class add-on stage per Art. 280a / 280b). All five columns arerequired=Falsewithdefault=None, so existing CCR-A1 (IR swap) and CCR-A2 (FX forward) fixtures continue to round-trip unchanged. The five-bucketcommodity_typeenum is also pinned in a new"trades"block ofCOLUMN_VALUE_CONSTRAINTS(input-domain validation, not a regulatory scalar — lives inschemas.pyper the data/engine separation policy).TradeBundledocstring insrc/rwa_calc/contracts/bundles.pyextended to list the five new columns alongside the previously-shipped FX leg-2 pair. No engine code touched — P8.33 is a foundation-only schema extension that unblocks P8.34 (hedging_sets.pyextension), P8.35 / P8.36 / P8.37 (per-asset-class adjusted notional + PFE add-on) and the CCR-A3..A10 acceptance scenarios. Pinned by 7 new contract tests intests/contracts/test_ccr_schemas_contract.py(one per new column verifying dtype + nullability +required is False+default is None, plus acommodity_type5-bucket constraint test and a column-count delta-of-5 test). Ref: CRR Art. 277(2)(c)-(d), 277(3)(b), 279b(1)(a), 279b(1)(c), 280a / 280b; BCBS CRE52.41-48, CRE52.60-69; PRA Rulebook CCR (CRR) Part (numerically identical). - SA-CCR FX support: leg-2 schema, FX adjusted notional, FX PFE add-on, and CCR-A2 acceptance scenario (P8.8 / P8.9 / P8.19 / P8.41 CCR-A2 slice): closes the FX gap left open by P8.12 (IR-only adjusted notional) and the FX side of P8.15 (hedging-set partition shipped, PFE add-on still IR-hardcoded at the time). Three schema columns and a fixture factory open the input surface:
notional_leg2: Float64andcurrency_leg2: String(both optional nullable) appended toTRADE_SCHEMAso FX forwards can carry both legs;Tradedataclass +to_dict()mirror the new columns; newmake_fx_trade()factory intests/fixtures/ccr/trade_builder.pyproduces the canonical 1-year GBP/USD outright-forward defaults (buy USD 100m / sell GBP 80m, MtM=0, delta=1). FX adjusted notional per CRR Art. 279b(1)(b) lands inengine/ccr/adjusted_notional.py::compute_adjusted_notional_fx(trades, base_currency, fx_rates)— joins both leg currencies against anfx_rateslookup (with an identity row so legs already in the reporting currency convert at 1.0), then applies the one-leg-is-base case (i) or both-legs-foreign max case (ii); coalesce-safe against any prior IR-branch output. Sibling fluent methodlf.ccr.adjusted_notional_fx(...)added. FX PFE add-on per CRR Art. 277a(2) + BCBS CRE52.55 lands as a refactor ofengine/ccr/pfe.py::compute_addon_per_asset_classinto a dispatcher that calls byte-equivalent_compute_addon_ir(existing IR three-bucket aggregation, unchanged) and new_compute_addon_fx(D_HS = signed sum per (NS, hedging_set_id); AddOn_HS = SF_FX × |D_HS|; AddOn_FX = simple sum across hedging sets — no cross-HS correlation for FX, unlike equity/commodity Art. 277a(3)).assign_hedging_setinengine/ccr/hedging_sets.pyextended to emithedging_set_id = "FX-{ns}-{min(ccy1,ccy2)}/{max(...)}"for FX rows, with order-independent currency-pair keying so EUR/USD and USD/EUR collapse to one hedging set. Pipeline-adapter wired:ccr_rows_to_exposuresnow acceptsbase_currency: str = "GBP"andfx_rates: pl.LazyFrame | None = None(threaded through fromconfig.base_currencyanddata.fx_ratesinengine/pipeline.py), so FX trades flow through end-to-end. CCR-A1 invariant preserved — the existing CCR-A1 expected outputs (RC=0, PFE=3,914,298.228, EAD=5,480,017.519, RWA=2,740,008.759) are byte-identical after the dispatcher refactor. New CCR-A2 acceptance scenario pinned attests/acceptance/ccr/test_ccr_a2_unmargined_fx_forward.pywith six assertions: 1y GBP/USD outright forward, USD 100m / GBP 80m, MtM=0, unmargined, counterparty CP_001 (institution CQS 2) → goldensaddon_aggregate=3,198,904.67,pfe_addon=3,198,904.67,ead_final=4,478,466.54,rwa_final=2,239,233.27(GBP). Goldens recorded intests/expected_outputs/ccr/CCR-A2.json. New unit-test suites cover the FX paths in isolation: 8 tests forcompute_adjusted_notional_fx(one-leg-base / both-foreign-max / negative-notional / non-FX rows / coalesce / missing-rate / LazyFrame return type) and 6 tests for the PFE FX branch (CCR-A2 hand-calc / signed-sum within HS / cross-HS sum / order-independent pair key / mixed IR+FX dispatcher / credit-row null). New specs atdocs/specifications/crr/ccr/adjusted-notional.mdanddocs/specifications/crr/ccr/fx-treatment.md(CCR spec subtree didn't exist before). Full suite green.SA_CCR_SUPERVISORY_FACTOR_FX = 0.04was already indata/tables/sa_ccr_factors.py:44from P8.7 — no new regulatory scalars needed. Open follow-ups: orchestrator-levelCalculationErroremission for missing FX rates (currently produces null adjusted_notional silently); FX options acceptance scenario; cross-rate triangulation for non-major pairs. Ref: CRR Art. 274(2), 275(1), 277(3)(a), 277a(2), 278, 279b(1)(b), 279c(1), 280 Table 1; BCBS CRE52.34 / CRE52.55; PRA Rulebook CCR (CRR) Part Chapter 3 §§3–5.
[0.2.14] - 2026-05-25¶
Added¶
- Audit cache extended to cover the full pipeline (was: CRM-only): the opt-in audit cache shipped in the previous iteration was scoped narrowly to CRM intermediates plus the aggregator's pre/post-CRM summary views — it answered the "is
H_fxfiring on my collateral" diagnostic but left every other pipeline stage opaque. This pass closes those gaps by sinking the audit-style frames each stage already produces. Eleven new always-present artifacts now land under<audit_cache_dir>/<run_id>/on every run: early stages —rating_inheritance.parquet(per-CP dual-track best-rating resolution, sunk in_run_hierarchy_resolver),classification_audit.parquet(per-exposure classification reason trail includingcp_entity_type, SME/retail gating, defaulted flag, concatenatedclassification_reasonstring, sunk in_run_classifier),re_split_audit.parquet(per-parent secured/residual split for property-collateralised SA exposures, sunk in_run_re_splitter— only present when at least one row triggered RE splitting); calculators —equity_calculation_audit.parquet(CIU mandate / look-through rationale, sunk in_run_equity_calculator),sa_results.parquet/irb_results.parquet/slotting_results.parquet/equity_results.parquet(pre-floor per-approach views fromAggregatedResultBundle, sunk in_persist_audit_artifacts; diff againstresults.parquetto attribute output-floor uplift back to a specific approach branch); conditional —floor_impact.parquet(per-row floor mechanics under Basel 3.1),supporting_factor_impact.parquet(CRR SME / infrastructure factor impact),securitisation_audit.parquet/securitisation_summary.parquet(only whensecuritisation_allocationsis supplied). All new sinks follow the existing architectural pattern: every call site invokessink_audit(...)(which lives inengine/materialise.py, the only sanctionedsink_parquetcaller perscripts/arch_check.py); no new collects are forced — each frame is sunk at a point where the producing stage already materialises it; failures continue to log WARNING and swallow per the original contract. Two new diagnostic recipes added todocs/specifications/audit-cache.md: "why was this exposure routed to SA / IRB / Slotting?" (pairclassification_auditwithrating_inheritance) and "did the output floor bind on this exposure?" (filterfloor_impact.parquetbyis_floor_binding). Pinned by 5 new integration tests intests/integration/test_audit_cache_pipeline.py(always-present artifact set covering all 19 always-present files, framework-conditional checks for CRR-onlysupporting_factor_impactand B3.1-onlyfloor_impact, per-row content regression forclassification_auditandrating_inheritance, schema-shape check across the four pre-floor per-approach parquets) and 8 new contract tests intests/contracts/test_audit_cache_contract.py(column-set regression guards forclassification_audit,rating_inheritance,equity_calculation_audit,sa/irb/slotting/equity_results,supporting_factor_impact). Default behaviour unchanged —audit_cache_dir=Noneremains a zero-overhead no-op. Ref: no regulatory change — pure observability / diagnostics extension. - Opt-in audit cache — per-run parquet snapshots of CRM intermediate frames: new
CalculationConfig.audit_cache_dir: Path | None = None(plus optionalaudit_cache_max_runs: int | None = None) opts the pipeline into persisting key intermediate frames as parquet under<audit_cache_dir>/<run_id>/. Motivated by a recurring diagnostic ask — "isH_fxfiring on my EUR property collateral against a GBP loan?" — that previously required users to re-runHaircutCalculator.apply_haircutsmanually on a fixture because no bundle field surfaced the per-collateralfx_haircut/collateral_haircut/value_after_haircutcolumns. With the cache enabled, the same pipeline run dropscollateral_haircuts.parquet(the missing diagnostic),crm_audit.parquet,collateral_allocation.parquet, the aggregator'spre_crm_summary/post_crm_summary/post_crm_detailed/summary_by_class/summary_by_approach/resultsparquets, and amanifest.jsoncarrying timestamps, framework, config snapshot, artifact list with byte sizes, and therun_idthat matches the correlation id on every log line. DefaultNone= feature off, zero overhead, zero new files. Architecture preserved: a newsink_audit(frame, config, name)helper lives inengine/materialise.py(the only sanctionedsink_parquetcaller perscripts/arch_check.py);CRMProcessorandPipelineOrchestratorinvoke it at points where the frame is already being materialised — no new collects, no streaming-plan disruption. Failures (disk full, permission denied) are logged WARNING and swallowed — audit caching must never break a real run.audit_cache_max_runstriggers an mtime-ordered prune after each run's artifacts commit so the cap is honoured exactly (N runs ⇒ at most N subdirs). Plumbed throughCreditRiskCalc.__init__for the API entry point. Diagnostic recipe documented indocs/specifications/audit-cache.md: opencollateral_haircuts.parquet, projectcollateral_reference, collateral_type, original_currency, exposure_currency, fx_haircut—fx_haircut == 0.0on RE / receivables / other_physical rows confirms the Art. 230 gate is working, anything non-zero on those types points to acollateral_typevalue not in the recognised synonym list (schemas.py:1034). Pinned by 12 unit tests intests/unit/observability/test_audit_cache.py(sink/prune semantics, atomic writes, name sanitisation, no-run-id warning path, swallowed failures), 6 integration tests intests/integration/test_audit_cache_pipeline.py(end-to-end layout, manifest schema, RWA-parity regression vs control, multi-run partitioning, prune-keeps-N-newest), and 3 contract tests intests/contracts/test_audit_cache_contract.py(column-set regression guards forcollateral_haircuts/collateral_allocation/crm_audit). Ref: no regulatory change — pure observability / diagnostics. - SA-CCR EAD wired in as synthetic exposure rows between the hierarchy resolver and the classifier (P8.20, batch 20260523-2023): new pipeline stage placed between
HierarchyResolverandExposureClassifier(Option B placement — pre-classifier so the existing counterparty-class lookup naturally routes CCR exposures). For each netting set inRawCCRBundle, the newccr_rows_to_exposuresadapter atsrc/rwa_calc/engine/ccr/pipeline_adapter.pyemits one synthetic exposure row withexposure_reference=f"ccr__{netting_set_id}",risk_type="CCR_DERIVATIVE",drawn_amount=ead_ccr, plus two new provenance columnssource_netting_set_idandccr_method=sa_ccr. Withdrawn_amountcarrying the SA-CCR EAD and undrawn/nominal/interest zeroed, the existing_initialize_eadproducesead_pre_crm = ead_ccrwith no CRMProcessor changes required. Schema additions are all nullable and backward-compatible:source_netting_set_idandccr_methodonRAW_EXPOSURE_SCHEMA/RESOLVED_HIERARCHY_SCHEMA/CLASSIFIED_EXPOSURE_SCHEMA/CRM_ADJUSTED_SCHEMA; newRiskType.CCR_DERIVATIVEandRiskType.CCR_SFTenum members (aligning the enum surface withVALID_RISK_TYPES_INPUTwhich already accepted these strings). Stage wrapped withstage_timer(logger, "ccr_sa_ccr")per the observability contract; the no-op path (data.ccr is None) skips the wrap so no log record is emitted in that branch. Pinned by 7 tests intests/integration/test_ccr_pipeline_integration.py(3 regression guards for no-CCR / error-accumulation / RWA-total contracts, 4 load-bearing assertions for CCR row emission + provenance + stage_timer + EAD wiring againstcompute_ead). Ref: CRR Art. 271 (CCR scope), Art. 272(4) (netting set), Art. 274(2) (EAD = α × (RC + PFE)); PRA Rulebook CCR (CRR) Part Art. 274-278. - CCR-A1 acceptance scenario + CCR rating-inheritance fix (P8.41, batch 20260524-1421): pins the first CCR vertical-slice acceptance scenario — single 10-year unmargined GBP IR swap, notional 100M, MTM=0, counterparty institution CQS 2 — with five assertions per CRR Art. 274/275/277a/278/279b/280 (RC, PFE add-on, EAD, exposure class, RWA). The new test surfaced an engine bug: CCR synthetic netting-set rows produced by
ccr_rows_to_exposureswere appended toresolved.exposuresAFTERhierarchy._attach_counterparty_ratinghad already joinedcqs/external_cqs/pd/internal_pdonto lending rows, so CCR rows reached the SA Institution lookup withcqs=Noneand fell through to the 100% unrated bucket (CRR Art. 121(1)) instead of CQS 2 → 50% (CRR Art. 120(1) Table 3). Fix is an additive_enrich_ccr_rows_with_ratingshelper in the orchestrator (engine/pipeline.py) that mirrors the hierarchy rating join for CCR rows betweenccr_rows_to_exposuresand thepl.concat. Expected-output JSON also corrected (values-only) to match the regulatory formula: the prior hand-calc used 3,653 calendar days from 2026-01-15 to 2036-01-15, but the 2036 Feb 29 leap day falls after the Jan 15 maturity so only 2 leap days (2028, 2032) are in scope → 3,652 days, E = 9.998631y not 10.001369y. Scenario invariants preserved:RC=0.0,EAD = 1.4 × PFE,RWA = 0.5 × EAD,exposure_class="institution",risk_weight=0.50. Pinned bytests/acceptance/ccr/test_ccr_a1_unmargined_ir_swap.pyandtests/expected_outputs/ccr/CCR-A1.json. Ref: CRR Art. 274, 275, 277a, 278, 279b, 280; PRA Rulebook CCR (CRR) Part. arch_checkcheck 10: enforce module References block on regulatory engine modules: new gate inscripts/arch_check.pythat requires every module underengine/anddata/schemas.pyto carry aReferences:block in its module docstring (the CLAUDE.md mandated shape). Reshape / format / IO helpers that carry no per-function regulatory citations are listed inREFERENCES_REQUIRED_EXEMPT. The check is a literal-token grep forReferences:— strict citation-form enforcement remains the job ofwatchfire(check 9) on@cites(...)decorators. This keeps protocol-only References blocks (e.g.loader.py-style) valid while preventing regulatory modules from shipping without any citation block. Surfaced and fixed the missing block onengine/crm/processor.py(cites CRR Art. 110, 111, 194, 213-217, 223-224, 230 plus COREP C07). Companion docs pass adds References blocks to 5 previously non-compliant regulatory modules:data/schemas.py(CRR Art. 110, 111, 112-134, 147-153, 153(5), 197-200, 213-217, 223-230, 501/501a),engine/hierarchy.py(lifts CRR Art. 131, 135, 136, 138, 139, 140 from existing internal@cites),engine/ccf.py(CRR Art. 111, 166 and PS1/26 Art. 166D),engine/pipeline.py(CRR Art. 92, 107, 110 plus PS1/26 output floor cross-ref), andengine/aggregator/_crm_reporting.py(CRR Art. 108-111, 213-217, 218-239 plus COREP C07 CRM scope). No runtime code paths touched; line-pinned allowlist intests/contracts/test_no_raw_over_on_nullable_keys.pybumped{440, 441}→{459, 460}to absorb the 19-line docstring prepend onhierarchy.py.data/schemas.pymodule docstring extended to surface CCR, settlement-risk, CIU look-through, FX, securitisation, and intermediate pipeline-stage schemas: the module docstring listed only the originally-scoped credit-risk inputs and drifted as later work added CCR (SA-CCR), settlement-risk, CIU look-through, FX rates, model permissions, securitisation allocation, and the pipeline-stage intermediate output schemas. The Key Data Inputs index now also listsCIU_holdingsandFX_rates, newCounterparty Credit Risk Inputs/Settlement Risk Inputs/Securitisationsections enumerate the relevant tables,Model_permissionsis surfaced under Configuration, anIntermediate Pipeline-Stage Schemassection enumerates the resolved / classified / CRM-adjusted bundles, and the References block expands to cite CRR Art. 132, 244-246, 271-279a, 285, 291, 295, 306-307, 378-380. Pure documentation; no runtime impact.
Changed¶
- Logging reset helper simplified:
_reset()in the four test files that exercise observability now iterateslogger.handlersdirectly instead of via an unnecessarylist(...)copy. Behaviour-preserving (logging.Logger.removeHandlerdoes not invalidate iteration over the handler list when each handler is removed in order). Touchestests/integration/test_fx_rate_autosync.py,tests/integration/test_logging_pipeline.py,tests/unit/observability/test_audit_cache.py,tests/unit/observability/test_logging.py. getattr(...)None-checks narrowed per-variable forty:ty checkflagged 8 call-non-callable errors intests/contracts/test_ccr_bundles_contract.pybecause thegetattr(bundles, "X", None)pattern returnsAny | None, andassert all(x is not None for x in [...])doesn't narrow individual names in the type-checker's view. Replaced with explicit per-variableassert X is not Noneso each name narrows fromAny | NonetoAnybefore being called as a constructor. Same runtime guard, same error messages on miss. Companionstatic check fixescommit cleans up two ruff findings insrc/rwa_calc/engine/crm/haircuts.pyandsrc/rwa_calc/engine/sa/supporting_factors.pysurfaced by the same gate run.- CCR
stage_timercaplog test re-enabled:test_stage_timer_emits_ccr_sa_ccr_recordpreviously captured zero records when run on an xdist worker that had previously executed anyCreditRiskCalc.calculate()-using test.configure_logging()setspropagate=Falseon therwa_calcnamespace logger, severing the descendantrwa_calc.engine.pipelinelogger from caplog's root-attached handler. The fix temporarily re-enables propagation for the scope of the test and restores the prior value in afinallyblock — mirrors the documented pattern already applied intests/unit/test_loader_optional_error_handling.pyandtests/unit/test_fx_rate_sync.py.
Fixed¶
- SME E* now nets the residential collateral value rather than dropping the entire BTL row (
engine/sa/supporting_factors.py): CRR Art. 501 defines E* as the total amount owed "excluding claims or contingent claims secured on residential property collateral". The engine previously implemented this carve-out by dropping entire BTL rows from the group sum, but that diverges from the parallel retail-threshold treatment inengine/hierarchy.py:2444-2447(CRR Art. 123(c)) which subtractsresidential_collateral_value(capped at drawn) per row. This change aligns the SME E* aggregation with the retail interpretation: each row's contribution to E* is nowdrawn − min(residential_collateral_value, drawn), so a non-BTL SME secured on residential property has the secured portion netted from E*, and a partially-secured BTL row contributes its unsecured residual rather than zero. Theis_btl ⇒ factor=1.0eligibility gate is unchanged; in the typical case where a BTL row's RRE coverage equals its drawn balance, its E* contribution still lands at 0. Behaviour change is CRR-only — supporting factors are removed under PS1/26. Pinned by 5 new tests inTestResidentialCollateralNettedFromEStar(partial coverage, full coverage, cap-at-drawn, lending-group spillover, backward-compat without the column); existingTestBTLExcludedFromSMEFactortests reframed to set explicitres_coll=drawnon BTL rows so their numerical expectations still hold. Ref: CRR Art. 501; mirrors retail-threshold treatment of CRR Art. 123(c). - Brand link in the docs hero updated to the new absolute URL (
docs/overrides/main.html): one-linehrefcorrection. - Skill quick-nav: CRR institution CQS 2 risk weight corrected (
.claude/skills/crr/SKILL.md): the Art. 120-121 quick-nav row claimed "UK CQS 2 = 30%", but 30% is the Basel 3.1 / PRA PS1/26 ECRA value. CRR Art. 120(1) Table 3 gives 50% for CQS 2 institutions, and no PRA instrument modifies that under CRR. The misleading hint seeded a wrong scalar in the P8.20 fixture (caught at W2 reviewer; tracked in batch 20260523-2023 follow-ups). Fix: 30% → 50%.
[0.2.13] - 2026-05-23¶
Fixed¶
- SME
drawn_exprhardened against NaN propagation (engine/sa/supporting_factors.py): a single NaN indrawn_amount,interest, oread_finalpoisoned the windowed sum overlending_group_reference, zeroingtotal_cp_drawnfor every exposure in the connected-clients group and dropping the SME supporting factor entirely.fill_nulldoes not catch NaN — addedfill_nan(0.0)beforeclip/sumon all three inputs.
[0.2.12] - 2026-05-23¶
Added¶
- Securitisation pool allocation — phase 1 flag + exclude (CRR Art. 109, Art. 244-246 / PS1/26 Art. 147A(1)(j)): new optional
securitisation_allocationsinput table maps originated exposures (loans, contingents, facility-undrawn parents) to one or more securitisation pools with a fractionalallocation_pct. A new lightweight pipeline stageSecuritisationAllocator(src/rwa_calc/engine/securitisation/allocator.py) resolves the table into a per-exposure lookup carryingsecuritisation_residual_pct(clipped to[0, 1]) andsecuritisation_pool_allocations(list-of-struct{pool_reference, allocation_pct}). The two columns ride through CRM, the calculators, and the aggregator unchanged. The aggregator (engine/aggregator/_securitisation.py) then multiplies every monetary column bysecuritisation_residual_pctso existing summaries (summary_by_class,summary_by_approach,pre_crm_summary,post_crm_*, output floor U-TREA / S-TREA, EL portfolio summary, supporting factor impact) naturally reflect only the on-balance-sheet portion —final_rwa × (1 - securitisation_pct)in the user's framing. Two new fields onAggregatedResultBundle:securitisation_summary(per-pool EAD / RWA placeholder / EL grouping derived by exploding the struct list) andsecuritisation_audit(per-exposure reconciliation showing parent EAD = residual + sum of pool slices). Five new validation codes (SEC001-SEC005) cover over-allocation, invalid pct, orphan exposure_reference, duplicate(exposure, pool)rows, and the SEC005 informational signal for fully-securitised exposures (residual = 0); SEC errors flow verbatim throughdata.errors(loader-validation channel) so the original codes survive into the bundle. Linearity property pinned bytests/integration/test_securitisation_pipeline.py::test_sec_06_residual_equals_pro_rata_via_linearity: residual_rwa == full_pipeline_rwa × residual_pct, demonstrating that late multiplication at the aggregator is equivalent to pro-rata CRM scaling for single-exposure metrics with directly-attached collateral. Known phase-1 limitation: counterparty- or facility-level shared collateral does not re-allocate when a sibling exposure is securitised — the unrelated siblings still see the full collateral allocation as if no securitisation had happened. This is documented indocs/specifications/securitisation-pool-allocation.md. Out of scope (deferred to phase 2): significant-risk-transfer assessment (Art. 244-246 conditions), securitisation RWA framework (SEC-SA, SEC-IRBA, SEC-ERBA — CRR Art. 259-264), tranche-level capital, originator retained interest. Wired throughRawDataBundle.securitisation_allocations,ResolvedHierarchyBundle.securitisation_audit,ClassifiedExposuresBundle.securitisation_audit,CRMAdjustedBundle.securitisation_audit,AggregatedResultBundle.{securitisation_summary, securitisation_audit}. NewSecuritisationAllocatorProtocolincontracts/protocols.py. Loader picks up the optionalsecuritisation/securitisation_allocations.parquetviaDataSourceRegistry. Pinned by 16 allocator unit tests (tests/unit/engine/securitisation/test_allocator.py), 11 aggregator-helper unit tests (tests/unit/engine/aggregator/test_securitisation_helpers.py), and 8 end-to-end integration tests covering SEC-01..SEC-08 (tests/integration/test_securitisation_pipeline.py). Ref: CRR Art. 109, Art. 244-246; PRA PS1/26 Art. 147A(1)(j). - SA-CCR engine subpackage — Tier 8 CCR integration phase 1 (P8.1 → P8.18): ~15 commits across the May 22-23 batches landed the SA-CCR engine scaffolding through the netting-set legal-enforceability gate. Highlights: new
src/rwa_calc/engine/ccr/subpackage scaffold (P8.4),RawCCRBundleand the optionalRawDataBundle.ccrfield (P8.2),CCRCalculatorprotocol (P8.3 placeholder for ty),CCRConfigplumbed throughCalculationConfig.crr()/.basel_3_1()factories (P8.6), CCR loader + 4 schemas (P8.5), supervisory factors / correlations / maturity constants moved todata/tables/per the architectural data/engine split (P8.7), trade-level CCR fixture builders for IR / FX / equity / credit / commodity / settlement (P8.40), SA-CCR EAD = α × (RC + PFE) per CRR Art. 274(2) (P8.17), supervisory delta linear ±1 (P8.13) and Black-Scholes options + CDO-tranche delta (P8.13-opt), adjusted-notional for IR (P8.12), maturity factor for unmargined and margined transactions with Art. 285 MPOR floors (P8.14, P8.14-marg), hedging sets + IR asset-class addon aggregation (P8.15), netting-set legal-enforceability gate (P8.18), replacement cost for margined transactions (P8.11), PFE multiplier and aggregate per Art. 278(3) (P8.16), QCCP trade exposures per Art. 306-307 (P8.25), failed trades DvP / non-DvP per Art. 378-380 (P8.24), and wrong-way-risk identification per Art. 291 (P8.27). The CCR pipeline stage wiring (P8.20) and first acceptance scenario (P8.41) land in 0.2.14. Ref: CRR Art. 271-280, 285, 291, 306-307, 378-380; PRA Rulebook CCR (CRR) Part.
Changed¶
- DQ008 warning consolidated into a single aggregate entry (was one warning per offending exposure):
ExposureClassifier._collect_beel_on_non_defaulted_warnings(engine/classifier.py) previously emitted oneCalculationErrorfor every row matching(is_defaulted=False ∧ beel>0). For portfolios whose A-IRB pipeline populatesbeelalongsidelgdon every advanced-IRB customer (the documented reason this check exists at all), that produced N repeated warnings, one per row — overwhelming any consumer that iteratesresult.errorsline-by-line. The helper now selectspl.len()instead of materialising the offending rows and returns a single-element list containing one aggregate warning carrying the total count, matching the CLS006 model-permission and CLS008 large-corp-revenue roll-up pattern used everywhere else in the classifier.beel_on_non_defaulted_exposure_warning(contracts/errors.py) factory signature changes from(*, exposure_reference, beel_value)to(*, n: int); the warning'sexposure_referenceandactual_valuefields are now unset because no single offender is referenced. Message reads"BEEL populated on {n} non-defaulted exposure(s); ...". Pinned by a newtests/unit/test_classifier.py::TestDefaultClassification::test_beel_warning_is_aggregated_across_multiple_offendersregression (N=3 offenders → exactly one DQ008 with"3 non-defaulted exposure"in the message); existing single-offender unit and acceptance scenarios updated to match the aggregate shape. Ref: PRA PS1/26 Art. 181(1)(h)(ii); CRR Art. 158(5).
Fixed¶
- H_fx no longer applied to funded non-financial collateral (Art. 230): the engine previously charged the 8% FX volatility haircut (scaled to ~11.31% at the 20-day secured-lending period by Art. 226(2)) on real estate, receivables, and other physical collateral whenever the collateral currency differed from the exposure currency. The citation chain on which this was built —
docs/specifications/crr/credit-risk-mitigation.mdattributed the charge to "CRR Art. 233" generically — is incorrect: Article 233 sits in CRR Sub-Section 2 Unfunded credit protection and governs guarantees / CDS only ("Where unfunded credit protection is denominated in a currency different from that in which the exposure is denominated …"). The funded non-financial collateral path is governed by Articles 229–230, whose LGD* formula compares the raw collateral valueCagainst the C* / C** thresholds in Table 5 with no FX volatility adjustment; FX risk is captured upstream by the spot-rateFXConverter. PS1/26 inherits CRR's silence on H_fx for Art. 230, so the fix is framework-agnostic. Engine change is a one-line gate on thefx_exprinsrc/rwa_calc/engine/crm/haircuts.py:203-210excludingcollateral_type ∈ NON_FINANCIAL_COLLATERAL_TYPES(new constant insrc/rwa_calc/data/schemas.pycoveringreceivables,real_estate,other_physicaland their accepted synonyms). Financial collateral (cash / gold / bonds / equity / covered bonds / life insurance / credit-linked notes) continues to receive the Art. 224 Table 4 H_fx — the comprehensive-method scope is unchanged. The guarantee H_fx (Art. 233(3)) inengine/crm/guarantees.pyis also unchanged. Capital impact: portfolios with non-financial collateral in a currency other than the exposure currency will see lower RWA — for a GBP corporate exposure secured by EUR commercial property, ES (haircut-adjusted collateral feeding LGD*) increases by ~11.31% at the 20-day liquidation default, which reduces LGD* and the corresponding F-IRB RWA. Pinned by 13 new tests intests/unit/crm/test_collateral_fx_mismatch.py::TestNonFinancialCollateralNoFxHaircut(real_estate / receivables / other_physical + framework variants + synonym coverage + a cash regression guard). Specs updated:docs/specifications/crr/credit-risk-mitigation.md(FX section narrowed, LGD* formula scope clarified, summary table row split into financial / unfunded / non-financial);docs/specifications/basel31/credit-risk-mitigation.md(FX Mismatch Haircut scope clarification). Ref: CRR Art. 224 Table 4 (financial); CRR Art. 233 (unfunded); CRR Arts. 229–230 (funded non-financial).
Performance¶
- Classifier diagnostics deferred behind a single materialise barrier — 100K pipeline runtime drops ~31 %:
ExposureClassifier.classify()(engine/classifier.py) previously triggered the upstream lazy plan three times per pipeline run — once for the BEEL-on-non-defaulted diagnostic (_collect_beel_on_non_defaulted_warnings, formerly called inline at the top ofclassify()), once for the model-permission roll-up (the.collect()inside_resolve_model_permissions_if_present), and a third time whenCRMProcessor._run_ead_pipelinehit its ownmaterialise_barrierafter provisions/CCF/init_ead. Polars does not cache intermediate results between.collect()calls, so each one re-executed the full hierarchy + classifier join plan from raw data. Profiling at 100K showed CRM's first barrier alone cost 2 622 ms of a 6 300 ms total — most of it redundant upstream work. The fix runs all lazy classifier transforms first (including the_resolve_model_permissionsjoin, separated from its diagnostic emit), inserts onematerialise_barrier(classified, config, "classifier_output")at the end ofclassify(), and then emits both diagnostics against the in-memory frame._resolve_model_permissions_if_presentis replaced by a pure-lazy_resolve_model_permissionscall plus a new post-materialise helper_emit_model_permission_diagnosticsthat runs the same filter / group-by / collect against the materialised frame for ~free. After the change,profile_stages.py --framework crr --irb fullreports CRM barrier #1 at 250 ms (was 2 622 ms) and total pipeline at 4 313 ms (was 6 300 ms) — a saving of ~1 987 ms / 31.5 % at 100K. Basel 3.1 / full IRB sees the same shape (total ~5 488 ms). No behaviour change to the diagnostic content — the warnings emitted are identical in code, message, count, and order to consumers iteratingresult.errors. Verified byuv run pytest tests/unit/ tests/contracts/(5 263 passed),uv run pytest tests/acceptance/ tests/integration/(1 007 passed), anduv run python scripts/arch_check.py(all checks passed — the newmaterialise_barriercall satisfies the "no raw .collect().lazy() outside materialise.py" rule). Baseline + after numbers captured indocs/perf/baseline-2026-05-22.md. Ref: no regulatory change.
[0.2.11] - 2026-05-19¶
Added¶
watchfirematrix coverage closed for CRR Art. 130-132 + 134-152 (SA other-items / ECAI methodology + IRB exposure-class chapter): the citation matrix atdocs/development/citation-matrix.mdpreviously walked straight from CRR Art. 129 (covered bonds) to Art. 133 (equity) and again from Art. 133 to Art. 153 (IRB RWEA), leaving the Other-Items / ECAI methodology block (Art. 130-141) and the entire IRB exposure-class chapter (Art. 142-152) invisible to anyone scanning the rendered matrix. This pass closes the CRR side of that gap end-to-end with a mix of new@cites(...)decorators on the functions that already realised the rules and inline "Out of scope — reason" notes on articles that are deliberately not cited. New decorators:CRR Art. 137on_eca_meip_rw_expr(engine/sa/namespace.py);CRR Art. 134on_apply_b31_risk_weight_overrides; stackedCRR Art. 134/CRR Art. 137on_apply_crr_risk_weight_overrides; stackedCRR Art. 135/136/138/139onHierarchyResolver._attach_counterparty_rating(engine/hierarchy.py); stackedCRR Art. 131/140onHierarchyResolver._apply_short_term_rating_override; stackedCRR Art. 141over the existingArt. 114onbuild_eu_domestic_currency_expr(data/tables/eu_sovereign.py); stackedCRR Art. 143/148/150onExposureClassifier._resolve_model_permissions(engine/classifier.py);CRR Art. 147onExposureClassifier._align_irb_exposure_classand stacked onExposureClassifier.classifyover the existingArt. 112; stackedCRR Art. 151over the existingArt. 153/154onapply_irb_formulas(engine/irb/formulas.py). Newfrom watchfire import citesimport landed onengine/hierarchy.py(the only target file that didn't already import the decorator). Coverage notes for the eight deliberately-not-cited articles in the dense 111-152 range —Art. 128(UK CRR omitted by SI 2021/1078),Art. 130(securitisation, separate calculator domain),Art. 132(UK CRR omitted; reintroduced underPS1/26 paragraph 132),Art. 142(definitions only),Art. 144/145/146/149(supervisory permission processes — input viamodel_permissions),Art. 147A(B3.1 amendment, decoration deferred until PS1/26 paragraph mapping is confirmed),Art. 152(IRB CIU look-through not implemented) — live in a newCRR_COVERAGE_NOTESdict at the top ofscripts/generate_citation_matrix.py.scripts/generate_citation_matrix.pyextended with aCRR_DENSE_RANGEcovering Art. 111-152: the renderer now guarantees every article in that range appears in the matrix as either an implementing-function collapsible (live@cites) or an italic out-of-scope block (notes), and raises if any article in the range has neither, so the matrix can never silently drop coverage; articles outside the dense range continue to render sparsely (only those with@cites). 10 new rows intests/contracts/test_watchfire_coverage.py::WHITELIST(84 parametrised cases, up from 66 after the Art. 115-119 round).docs/development/citation-tracking.mdextended with a paragraph distinguishing dense vs sparse matrix coverage. No runtime behaviour change —@citesis a no-op decorator. Verified end-to-end:uv run python scripts/generate_citation_matrix.py— matrix regenerated, CRR section now spans### CRR Art. 111through### CRR Art. 152with no gaps;uv run python scripts/arch_check.py—all checks passed;uv run pytest tests/contracts/test_watchfire_coverage.py— 84 passed; fulltests/contracts/minus pre-existingtest_no_raw_over_on_nullable_keys.pyfailure — 293 passed;uv run pytest tests/unit/— 4,934 passed;uv run zensical build— site builds cleanly. Basel 3.1 / PS1/26 citations in the same article range are intentionally out of scope for this pass — a future review will close those gaps; where a function already carries a PS1/26 cite (e.g._append_ciu_branches), it's left untouched. Ref: CRR Art. 130-152;scripts/generate_citation_matrix.py:49-152.watchfirematrix coverage closed for CRR Art. 115-119 (SA exposure-class block): the citation matrix atdocs/development/citation-matrix.mdwalked straight from CRR Art. 114 (central govt) to CRR Art. 120 (rated institutions), under-representing five fully-implemented SA exposure classes — RGLA (Art. 115), PSE (Art. 116), MDB (Art. 117), international organisations (Art. 118), and institutions scope/umbrella (Art. 119). Decorators added insrc/rwa_calc/data/tables/crr_risk_weights.pyfollowing the existing builder-layer precedent set bybuild_institution_guarantor_rw_expr(Art. 120/121) andbuild_corporate_guarantor_rw_expr(Art. 122):@cites("CRR Art. 115")on_create_rgla_df,@cites("CRR Art. 116")on_create_pse_df,@cites("CRR Art. 117")on_create_mdb_df, and@cites("CRR Art. 119")stacked outermost over the existingArt. 120/Art. 121pair onbuild_institution_guarantor_rw_expr. Art. 118 had no analogue_create_*_df()builder (only the bareIO_ZERO_RWconstant + an inline 0% branch insa/namespace.py), so a thin_create_io_df()builder was introduced for symmetry, driven by a newINTERNATIONAL_ORG_RISK_WEIGHTSdict keyed onCQS.UNRATEDso it round-trips through the shared_build_cqs_rw_dfhelper; the SA branch is unchanged. Five rows added totests/contracts/test_watchfire_coverage.py::WHITELIST(the institution row now expects the three-citation tuple("CRR Art. 119", "CRR Art. 120", "CRR Art. 121")per the outermost-first convention).docs/development/citation-matrix.mdregenerated viascripts/generate_citation_matrix.py— now renders### CRR Art. 115through### CRR Art. 119headings between the existing 114 and 120 sections. No runtime behaviour change (@citesis a no-op decorator). Verified:uv run pytest tests/contracts/test_watchfire_coverage.py— 66 passed (was 61);uv run watchfire check—fatal=0 warn=0(all five articles resolve against the bundled CRR index);uv run python scripts/arch_check.py—all checks passed; SA-class acceptance subset (RGLA/PSE/MDB/IO/institution) — 48 passed. Ref: CRR Art. 115-119;src/rwa_calc/data/tables/crr_risk_weights.py.
Changed¶
beel > 0no longer triggers defaulted treatment; new DQ008 warning surfaces the input contradiction:engine/classifier.py::_build_is_defaulted_expris narrowed from a three-way OR (cp_default_status | row-level is_defaulted | beel > 0, landed in P1.127, shipped in v0.2.10) back to a two-way OR (cp_default_status | row-level is_defaulted). PRA Rulebook (IRB Part Rule 1.3), PS1/26 Art. 158(5), and Art. 181(1)(h)(ii) define BEEL strictly as the firm's best-estimate-EL on a defaulted exposure, but firms whose A-IRB model pipelines emit a BEEL-style value alongsidelgdfor every advanced-IRB customer would otherwise see those rows silently mass-flagged as defaulted (routed through SA Art. 127 or IRB Art. 153(1)(ii) / 154(1)(i)). The contradictory(is_defaulted=False ∧ beel>0)combination now surfaces as one non-blockingDQ008warning per offending exposure (newERROR_BEEL_ON_NON_DEFAULTED_EXPOSURE+beel_on_non_defaulted_exposure_warningfactory incontracts/errors.py; emitted by newExposureClassifier._collect_beel_on_non_defaulted_warningswhich reads the derivedis_defaulted, so rows that the counterparty cascade legitimately routes to defaulted are not falsely flagged).beelremains an A-IRB defaulted parameter — consumed byengine/irb/adjustments.py(K = max(0, LGD − BEEL)per Art. 154(1)(i)) and by Pool C of the Art. 158(5) EL amount when the row is genuinely defaulted. New@cites("CRR Art. 178")/@cites("CRR Art. 153")decorators on the derivation helper; Art. 158(5) citation already sits on the consumption sites inirb/adjustments.pyand is unchanged.is_defaultedpromoted to a first-class optional Boolean onLOAN_SCHEMA,CONTINGENTS_SCHEMA, andFACILITY_SCHEMA(data/schemas.py) so the row-level flag P1.127 already supports has a documented home — defaults toFalse. Migration note for firms whose loaders populatebeelon non-defaulted rows: either (a) restrictbeelto defaulted rows only in the loader (regulator-correct, no engine change needed) or (b) treat the resultingDQ008warnings as informational (the calc is unaffected on those rows — BEEL is not consumed when the derivedis_defaultedis False). Pinned bytests/acceptance/crr/test_beel_does_not_trigger_default.py(3 scenarios: A-IRB performing withbeel>0→ not defaulted + one DQ008; cp-default withbeel>0→ defaulted + no DQ008; row-flag withbeel=0→ defaulted + no DQ008) and four new truth-table cases intests/unit/test_classifier.py::TestDefaultClassification. P1.127 regression guard updated: theLN-P1127-Ddefaulted fixture now sets explicitis_defaulted=Trueon the loan row (was relying on the removedbeel>0OR branch); the three Pool B AVA /other_own_funds_reductionsassertions remain unchanged and green. Benchmark data generators (tests/benchmarks/data_generators.py) extended to populate the newis_defaultedcolumn on synthetic facility / loan / contingent rows. Ref: CRR Art. 178, Art. 153(1)(ii) / 154(1)(i), Art. 158(5); PS1/26 Art. 181(1)(h)(ii); PRA Rulebook IRB Part Rule 1.3 (BEEL definition).
Performance¶
- Benchmark scales pruned: 10M removed entirely, 1M gated behind
-m scale_1mopt-in: the 10M-scale pipeline + hierarchy benchmarks were unrunnable on developer laptops — synthetic data generation alone OOMs at ~10M counterparties, and even when the cached parquet existed the pipeline took ~20 minutes. Their only useful regression signal was duplicated by 100K +tests/benchmarks/profile_plan.pyplan-complexity inspection, so they're deleted in full:TestPipelineBenchmark10M(tests/benchmarks/test_pipeline_benchmark.py),TestHierarchyBenchmark10M(tests/benchmarks/test_hierarchy_benchmark.py), thebenchmark_config_10m/dataset_10m/dataset_10m_statsfixtures, and thescale_10mmarker registration. The 1M scale remains in the codebase for opt-in profiling on a workstation but is now belt-and-braces gated by both@pytest.mark.slow(existing) and a new@pytest.mark.scale_1m-driven addopts exclusion (pyproject.toml:116—-m 'not slow and not stress and not scale_1m'), so dropping theslowmarker by accident in future cannot re-enable 1M in the dev loop. The 10K and 100K benchmarks continue to run untimed in the dev loop (via the existing--benchmark-disableflag) so any pipeline regression that broke the calculator at scale would still surface as a test failure. Verified byuv run pytest tests/benchmarks/ --collect-only(27 selected, 5 deselected, no 10M classes listed); explicit 1M opt-in confirmed byuv run pytest tests/benchmarks/ -m scale_1m --collect-only(5 selected). Ref: no regulatory change.
[0.2.10] - 2026-05-16¶
Added¶
- Short-term ECAI flag moved from facility row to ratings row (refactor): short-term ECAI assessments under PRA PS1/26 Art. 120(2B) Table 4A and Art. 122(3) Table 6A are issue-specific (attached to a particular exposure), so the
has_short_term_ecaiBoolean has been removed fromFACILITY_SCHEMAand replaced by three new columns onRATINGS_SCHEMA:is_short_term: Boolean(default False),scope_type: String(facility/loan/contingent), andscope_id: String(the matching identifier). A newRatingScopeenum lives indomain/enums.py.HierarchyResolvergained_apply_short_term_rating_override(engine/hierarchy.py), which joins the short-term rating row(s) against each exposure using(counterparty_reference, scope_type, scope_id)and overrides the counterparty-levelcqsplus the derivedhas_short_term_ecaicolumn. The SA branches in_b31_append_institution_maturity_branches/_b31_append_corporate_maturity_branches(engine/sa/namespace.py) drop the original-maturity sub-gate from the Table 4A / Table 6A clauses — when a short-term rating row is attached, the engine trusts the producer and routes via Table 4A / Table 6A unconditionally (the regulatory maturity test ≤ 3m is now a producer-side responsibility). The old facility-level OR-broadcast ofhas_short_term_ecaiin_propagate_facility_qrre_columnshas been deleted. New loader DQ rule (contracts/validation.py::_validate_short_term_rating_scope) flags rating rows whereis_short_term=Trueis paired with null scope columns, and warns on stray scope values for non-short-term rows. Tests / fixtures migrated:tests/fixtures/p1_103/,tests/fixtures/p1_105/,tests/fixtures/api_validation/build_mandatory_only.py,tests/fixtures/p1_128/p1_128.py; new contracttests/contracts/test_short_term_rating_override.pypins (i) override fires regardless of maturity, (ii) scope-id mismatch is a no-op, (iii) absent short-term row falls back to counterparty long-term CQS. Ref: PRA PS1/26 Art. 120(2B); Art. 122(3); BCBS CRE20.20 / CRE20.45. @cites("CRR Art. 501a")re-instated oncalculate_infrastructure_factor(watchfire 0.3.1): the workaround inline comment atengine/sa/supporting_factors.py:114-116was added against watchfire 0.3.0, whose parser rejected alphanumeric article suffixes —501acould neither parse nor validate, so the function was left unannotated to avoid mis-citing the umbrellaArt. 501(SME factor). watchfire 0.3.1 (pinned inpyproject.toml:38) fixes both halves:parser.py:121regex now acceptsr"(\d+[a-z]*)", and the bundled CRR index (.venv/Lib/site-packages/watchfire/data/index.parquet) carries 49 rows for article501a. Decorator re-added; 3-line workaround comment deleted. Stale prose inCLAUDE.mdanddocs/development/citation-tracking.mdrewritten to distinguish parser support (now general) from index coverage (still missing123B/110A, which remain Basel-3.1 amendments with no CRR equivalent — those sites atengine/sa/namespace.py:1864,1934correctly citePS1/26, paragraph …already and are unchanged). No runtime behaviour change —citesis a no-op decorator. Verified end-to-end:uv run watchfire matrix --instrument CRR --article 501alistscalculate_infrastructure_factor. Ref: PRA PS1/26 Art. 501a (infrastructure supporting factor); CRR2 EU 2019/876.-
watchfirecitation tracking — fan-out to 54 annotated functions + strict gate + contract test (watchfire 0.3.0): built on the prior pilot to annotate the full breadth of the engine and regulatory data tables. Dependency bumped fromwatchfire>=0.2.0towatchfire==0.3.0inpyproject.toml:38(also picks up the[tool.watchfire].rulebook_versionbump from2026-05-14to2026-05-15). 0.3.0 stacks@citesdecorators into a tuple at__watchfire__rather than overwriting (multi-citation now first-class), accepts alphanumeric article and paragraph suffixes (CRR Art. 123B,PS1/26, paragraph 110A), and ships an expanded PS index (4,498 PS rows, up from a single stub). New@cites(...)decorators landed on ~50 additional functions acrossengine/{sa,irb,crm,re_splitter,ccf,equity,slotting,classifier,aggregator}anddata/tables/{crr_risk_weights,b31_risk_weights,eu_sovereign,firb_lgd,haircuts}— stacked CRR + PS1/26 forms used wherever one function implements both frameworks (e.g._pd_floor_expressioncarriesCRR Art. 163andPS1/26, paragraph 163). The pilot'sapply_currency_mismatch_multiplierworkaround comment was deleted: it now citesPS1/26, paragraph 123Bprecisely. Three Basel-3.1-only amendment articles that don't exist in the pre-2027 CRR index (Art. 123B,Art. 110A,Art. 501ainfrastructure) cite the PS1/26-form only or, where no valid CRR-equivalent exists, are left intentionally unannotated with an inline explanation (calculate_infrastructure_factor).scripts/arch_check.py::check_watchfire_citations()simplified to strict mode — PS / PRA Rulebookunknown_articlefindings are no longer downgraded to soft warnings now that the index is mature; only ASTunresolvedcases remain soft. Newtests/contracts/test_watchfire_coverage.pywhitelists all 54 annotated functions with their expected canonical citation tuples (61 parametrised test cases); accidental decorator removal during refactors will fail this test with a named row rather than a silent matrix shrink. Newscripts/generate_citation_matrix.pyregeneratesdocs/development/citation-matrix.mdby invokinguv run watchfire matrix --format markdownonce per instrument and stitching the tables.docs/development/citation-tracking.mdextended with sections covering the coverage matrix, the regression test, and the strict gate. Verified end-to-end:uv run watchfire checkresolves 76 citations cleanly;uv run python scripts/arch_check.pyreportsall checks passedwith zero warnings;uv run pytest tests/contracts/test_watchfire_coverage.py— 61 passed;uv run watchfire matrix --instrument CRRenumerates every CRR-side annotation;uv run pytest tests/unit/irb/— 212 passed (no runtime regression from decorator stacking). Ref:pyproject.toml:38,154-160; watchfire 0.3.0 release. -
watchfirecitation tracking — pilot wire-up + 5 reference annotations: integratedwatchfire>=0.2.0(already inpyproject.toml:38) as the project's static citation validator. New[tool.watchfire]table inpyproject.tomlpinsrulebook_version = "2026-05-14"and scopes scanning tosrc/rwa_calc/engine+src/rwa_calc/data/tables.scripts/arch_check.pynow invokeswatchfire.checks.run_checkvia its Python API as the final gate step (newcheck_watchfire_citations());parse_failure,unknown_instrument,version_mismatch, and CRR/Delegated-Regulationunknown_articlefindings are fatal, while PS / PRA Rulebook / SSunknown_articlefindings and ASTunresolvedcases are downgraded to soft warnings until the upstream rulebook index ships richer non-CRR coverage. Five pilot@cites(...)decorators landed on canonical IRB / SA entry points:calculate_kandcalculate_correlation(engine/irb/formulas.py,CRR Art. 153(1));IRBLazyFrame.apply_pd_floorandapply_lgd_floor(engine/irb/namespace.py, stackedCRR Art. 163/CRR Art. 164overPS1/26, paragraph 163/PS1/26, paragraph 164);SALazyFrame.apply_currency_mismatch_multiplier(engine/sa/namespace.py, instrument-levelPS1/26— the preciseArt. 123Bsub-paragraph is unparseable by watchfire 0.2.0 today because the parser rejects alphanumeric paragraph suffixes; an inline comment records the pending upstream fix). Inner stacked decorators do not yet surface inwatchfire matrix(watchfire 0.2.0__watchfire__holds the outermost citation only) — once upstream stacks citations into a tuple, the inner PS1/26 references will activate with no source-code change here. New developer-docs pagedocs/development/citation-tracking.mddocuments the@citesconvention, canonical citation grammar, parser limitations, and CLI invocations;CLAUDE.md"Documentation" section gains a "Citation tracking" subsection so future contributors annotate as a matter of course. Verified end-to-end:uv run python scripts/arch_check.pyreportsall checks passedwith 3 PS1/26 soft warnings;uv run watchfire matrix --instrument CRR --format markdownlists CRR Art. 153 / 163 / 164 mapped to the expected functions. Follow-up work (Step 2 of the rollout plan) will fan out annotations across the remaining ~20 public namespace methods. Ref:pyproject.toml:38; watchfire upstream tracker for multi-citation + PS1/26 index expansion. -
B3.1 Art. 123B(1) currency-mismatch scope narrowed to retail / RRE only (P1.94 sub-item (f), batch 20260510-1500):
apply_currency_mismatch_multiplier(engine/sa/namespace.py:1880-1892) was over-firing the 1.5x B3.1 multiplier on commercial RE because the in-scope predicate used substring matching (_upper_class.str.contains("RETAIL"|"MORTGAGE"|"RESIDENTIAL"|"COMMERCIAL"|"CRE")) —COMMERCIAL_MORTGAGEmatched on both"COMMERCIAL"and"MORTGAGE". PRA PS1/26 Art. 123B(1) restricts the multiplier to retail (Art. 112(h)) and residential RE (Art. 112(i)) classes only; commercial RE (Art. 112(j) per Art. 124H/124I) is OUT of scope. Fix narrows the gate to exact matchpl.col("exposure_class").is_in(["retail_other","retail_qrre","retail_mortgage","residential_mortgage"]). No new regulatory scalars (uses existingExposureClassstring values fromdomain/enums.py). Pre-fix counterfactual on a 1m EUR commercial-mortgage exposure with GBP borrower-income: RW=1.50, RWA=1,500,000 (BUG); post-fix RW=1.00, RWA=1,000,000. Discriminating fixture: 3-arm test (retail_other in-scope, commercial_mortgage out-of-scope, corporate sanity-anchor); test routes viacalculate_single_sa_exposureto bypass classifier and pin the multiplier predicate directly (mirrors P1.94a precedent). Pinned bytests/acceptance/basel31/test_p1_94f_currency_mismatch_scope_residential_re.py(10 tests with load-bearing anti-assertionsRW != 1.50andRWA != 1,500,000+ cross-arm scope-boundary check). P1.94a regression (7 tests) green. Sub-items (b)/(d)/(e)/(g) remain open. Ref: PRA PS1/26 Art. 123B(1); BCBS CRE20.93. -
B3.1 corporate guarantor at CQS 3 substituted RW = 75% under IRB SA-fallback (P1.122 sub-claim (a), batch 20260510-1500):
_compute_guarantor_rw_sa(engine/irb/guarantee.py:269-281) was hardcoded to the CRR Art. 122 Table 5 corporate-CQS ladder (CQS 3 = 1.00) with nois_basel_3_1branch, so a B3.1 IRB borrower whose corporate guarantor lacked aninternal_pd(forcingguarantor_approach = "sa"perengine/crm/guarantees.py:254-266) silently substituted at CRR weights — over-stating capital by 25 pp (1.00 → 0.75) on the guaranteed portion. Fix extracts newbuild_corporate_guarantor_rw_expr(cqs_col: str, is_basel_3_1: bool)helper indata/tables/crr_risk_weights.py(mirrors thebuild_institution_guarantor_rw_exprprecedent established by P1.95 / P1.122 sub-claim (c) / v0.2.22-v0.2.23), dispatching toB31_CORPORATE_RISK_WEIGHTSunder B3.1 andCORPORATE_RISK_WEIGHTSunder CRR. The corporate branch in_compute_guarantor_rw_sanow callsbuild_corporate_guarantor_rw_expr("guarantor_cqs", config.is_basel_3_1)instead of the inlinepl.when(...cqs.is_in([3,4])).then(1.0)ladder. No regulatory scalars inengine/. Discriminating row: £1m B3.1 unrated corporate borrower under FIRB + 100%-coverage guarantee from rated CQS-3 corporate guarantor withinternal_pd=null→ guarantor sub-rowrisk_weight = 0.75,rwa = 750,000post-fix (was 1,000,000); CRR same construction stays at 1.00 / 1,000,000 (regression-pinned); cross-arm RWA delta = 250,000. Distinct from P1.110 (which usesPermissionMode.STANDARDISEDand exercises the SA path's already-framework-gated_build_guarantor_rw_expr). Pinned bytests/acceptance/basel31/test_p1_122a_b31_corporate_cqs3_guarantor_irb_sa_fallback.py(3 tests: B31 75% / CRR 100% regression / Δ=250k) plus 7-test P1.110 sibling regression. Sub-claim (b) (unrated institution SCRA grades) remains open. Ref: PRA PS1/26 Art. 122(1) Table 6, Art. 235; CRR Art. 122 Table 5. -
OutputFloorSummaryrename + new genuine portfolio total (P2.20, batch 20260510-1500): the field formerly namedtotal_rwa_post_flooronly contained the floored modelled (IRB + slotting) component, not the SA / equity contributions — its name implied a portfolio-wide quantity but the value was modelled-only. PRA PS1/26 Art. 92(2A) definesTREA = max(U-TREA, x · S-TREA + OF-ADJ); the floor binds on the modelled subset (Art. 92(3A) S-TREA recomputes modelled in SA), and SA + equity pass through unchanged. RenamedOutputFloorSummary.total_rwa_post_floor→floored_modelled_rwa(modelled-only scope, identical arithmetic:u_trea + shortfall); addedsa_rwa_total: float = 0.0andequity_rwa_total: float = 0.0; redefinedtotal_rwa_post_floor: float = 0.0asfloored_modelled_rwa + sa_rwa_total + equity_rwa_total. NewSA_APPROACHESandEQUITY_APPROACHESfrozensets inengine/aggregator/_schemas.py(allowlisted inscripts/arch_check.py::VALIDATION_ENUM_ALLOWLISTalongside existingIRB_APPROACHES).engine/aggregator/_floor.pypopulates the new fields at both summary-construction sites via private_portfolio_sa_equity_totals(combined)helper that filtersrwa_pre_floorbyapproach_appliedmembership in SA / equity sets — no new.collect()boundaries, noaggregator.pyedit.engine/comparison.pytotal_rwa_post_floorcolumn onTransitionalScheduleBundle.timelineis a different field on a different bundle; explicitly NOT renamed. Hand-calc: SA=100, IRB=200, slotting=50, equity=30, x=0.725, S-TREA back-solved so floor binds at floored_modelled=280 → total_rwa_post_floor=410. Consumer migrations in 5 test files (test_of_adj.py,test_portfolio_level_floor.py,test_output_floor_skip_transitional.py,test_corep.pyTestOF0201/TestC0700Col0020,test_stress_pipeline.py). Pinned bytests/unit/test_p2_20_total_rwa_post_floor_naming.py(9 tests: dataclass field existence introspection + algebraic identity + SA/equity totals from a synthetic in-test 4-row LazyFrame + load-bearing pure-value comparison). Ref: PRA PS1/26 Art. 92(2A)/(3A);engine/aggregator/_floor.py:253. -
Sovereign / institution PD floors as first-class
PDFloorsconfig fields (P2.36, batch 20260510-1500):PDFloors(src/rwa_calc/contracts/config.py:56-111) previously exposedcorporate,retail_mortgage,retail_qrre_transactor,retail_qrre_revolver,retail_other,purchased_receivables_qrre, but notsovereignorinstitution— so the engine obtained the regulatorily-required 0.05% floor (PRA PS1/26 Art. 160(1)) for sovereign / institution exposures only by accident, falling through to the corporate-floor branch in_pd_floor_expression(engine/irb/formulas.py:51-126). Added explicitsovereign: Decimalandinstitution: Decimalfields toPDFloors;.basel_3_1()factory returnsDecimal("0.0005")for both (PRA PS1/26 Art. 160(1));.crr()returnsDecimal("0.0003")for both (CRR Art. 160(1) uniform).PDFloors.get_floor()extended with sovereign / institution dispatch ahead of the corporate fallback._pd_floor_expressionextended with two newpl.when(exposure_class == ...)branches (CENTRAL_GOVT_CENTRAL_BANK →floors.sovereign; INSTITUTION →floors.institution) inserted before the final.otherwise(corporate); both new floor values added to the all-equal optimisation set. Discriminating row: sovereign B3.1 IRB exposure (PD=0.0001 input → floored to 0.0005, M=2.5y, LGD=0.40, no 1.06 scaling) yields RW≈0.174677 / RWA≈174,677; overridingpd_floors.sovereign=Decimal("0.001")viadataclasses.replacedrives RW→0.263591 / RWA→263,591 — proving the dispatch path is independent of the corporate fallback (which still returns 0.0005 unchanged). CRR uniform-floor cross-check: sovereign PD=0.0001 floors to 0.0003 (not 0.0005). Pinned bytests/unit/config/test_p2_36_sovereign_institution_pd_floors.py(14 tests: 4 field-existence on.basel_3_1()and.crr()factories +get_floordispatch tests + load-bearing override-regression tests for sovereign and institution). Ref: PRA PS1/26 Art. 160(1); CRR (EU 575/2013 onshored) Art. 160(1). -
CRR Art. 123 second subparagraph payroll/pension 35% RW (P2.17, batch 20260510-1300):
_apply_crr_risk_weight_overridesinengine/sa/namespace.pynow branches onis_payroll_loanahead of the flat-75% retail catch-all (mirrors B3.1 ordering), so qualifying CRR retail payroll/pension loans receive the CRR2 (Reg (EU) 2019/876, F68) Art. 123 second subparagraph 35% RW instead of the conservative 75% default. The 35% scalar is reused fromB31_RETAIL_PAYROLL_LOAN_RW = Decimal("0.35")(data/tables/b31_risk_weights.py:252) — identical under PRA PS1/26 Art. 123(3)(a-b) and CRR Art. 123 second subparagraph; cross-framework reuse is documented inline. Caller-attestedis_payroll_loanflag onLOAN_SCHEMA(already present pre-fix); the four cumulative Art. 123 conditions (a)–(d) are not engine-validated. Capital impact on a 50k payroll loan: RWA drops 37,500 → 17,500 (40% reduction). Pinned bytests/acceptance/crr/test_p2_17_crr_payroll_loan_35pct_rw.py(3 retail loans: 2 payroll @ 35% + 1 control @ 75%; anti-regressionrisk_weight != 0.75guards on payroll arms). Ref: CRR Art. 123 second subparagraph (CRR2 amendment); PRA PS1/26 Art. 123(3)(a-b) (parity check). -
PSM LGD source switch — Art. 236(1)(a)(i) option (i) (P2.43, batch 20260510-1300): new
psm_lgd_source: Literal["option_i", "option_ii"] = "option_ii"field onIRBPermissions(contracts/config.py) exposes PRA PS1/26 Art. 236(1)(a)(i)'s borrower-unprotected LGD route for F-IRB unfunded credit protection. Pre-fix the engine hard-wired option (ii) (guarantor F-IRB scalar); option (i) was inaccessible._apply_parameter_substitution(engine/irb/guarantee.py) now branchesLGD_covered: option (i) →pl.col("lgd")(post-apply_firb_lgdcolumn carries the borrower's seniority-correct supervisory value, e.g. 0.75 for subordinated); option (ii) → existing per-row guarantor F-IRB selection._apply_no_better_than_direct_floorrefactored to takepsm_lgd_expr+direct_lgd_exprseparately so the Art. 160(4) NBD comparison always uses the option (ii) guarantor scalar regardless of the switch (regulatory invariant — option (i) cannot give a better result than treating the protection as a direct exposure to the guarantor)._adjust_expected_lossmirrors the same branch (Art. 236(1A)(b)).CalculationConfig.irb_permissionswidened fromfield(init=False)toIRBPermissions | None = Nonewith__post_init__deriving the framework-default — required to supportdataclasses.replace(config, irb_permissions=...); runtime invariant guarantees non-None after construction. Discriminating row: B3.1 corporate borrower (subordinated, PD=0.05, M=2.5y, EAD=1M GBP) fully guaranteed by senior non-FSE corporate guarantor (PD=0.005). Option (ii) RW≈0.619 / RWA≈619k / EL≈2.0k; option (i) RW≈1.161 / RWA≈1.16M / EL≈3.75k; cross-arm RWA delta ≈541k. Default behaviour preserved (option_ii). Pinned bytests/acceptance/basel31/test_p2_43_psm_lgd_source_switch.py(3 functional tests + 6 fixture sanity guards). Ref: PRA PS1/26 Art. 236(1)(a)(i)/(1A)(b); Art. 161(1)(a)(aa)(b); Art. 160(4); BCBS CRE32. -
B3.1 Art. 123B(2)
is_hedgedflag gates currency-mismatch multiplier (P1.94 sub-item (a), batch 20260510-0530): SAapply_currency_mismatch_multiplier(engine/sa/namespace.py) now AND-gatesmismatch_applieswith~is_hedged.fill_null(False), so exposures attestingis_hedged=Trueskip the 1.5x multiplier per PRA PS1/26 Art. 123B(2) hedge exemption. Newis_hedged: ColumnSpec(pl.Boolean, default=False, required=False)onLOAN_SCHEMA(data/schemas.py);null/missing falls back to the conservative behaviour (multiplier still fires under FX mismatch). Pre-fix the multiplier fired for every mismatched-currency exposure regardless of hedge status — over-stating capital on hedged retail / RRE rows by 50% (RW × 1.5 capped at 150%). Discriminating fixture: pairedretail_otherexposures, EUR loan vs GBP borrower-income, identical exceptis_hedged— hedged arm now RW=0.75/RWA=75k (multiplier suppressed); unhedged arm remains RW=1.125/RWA=112.5k. Pinned bytests/acceptance/basel31/test_p1_94a_is_hedged_gates_currency_mismatch.py(7 tests: 3 hedged + 3 unhedged regression-pin + 1 cross-arm RW-delta=0.375). Audit booleancurrency_mismatch_multiplier_appliedcorrectly reflects the gate decision. Out of scope (remain open on P1.94): (b) 90%-coverage hedge test, (d) revolving instalment 123B(2A), (e) pre-2027 portfolio fallback 123B(3), (f) scope narrowing to retail (h)/(i) only, (g) CR5 pre-multiplier RW reporting. Ref: PRA PS1/26 Art. 123B(1)/(2); BCBS CRE20.88. -
B3.1 Art. 226(1) 20-day secured-lending + FX-mismatch reval-scaling acceptance regression-guard (P2.18, batch 20260510-0335):
tests/acceptance/basel31/test_p2_18_art_226_1_b31_secured_lending_fx.py(7 tests) pins the previously uncovered Basel 3.1 / 20-day secured-lending T_m / USD-collateral-vs-GBP-loan / weekly-reval (N_R=5) corner. Engine already implements Art. 226(1)sqrt((NR + T_m - 1) / T_m)symmetrically against both collateral and FX haircuts (engine/crm/haircuts.py:144-207); plan-bullet was stale. Discriminator:ead_final ≈ 239,427.40post-fix vs the pre-Art.226(1)-scaling counterfactualead_final ≈ 227,279.22— the test asserts the latter as a NOT-equal regression guard so any regression that drops the reval factor on either channel fails loudly. New fixture builder + parquets attests/fixtures/p2_18/;tests/fixtures/generate_all.pyextended. No engine change required. Ref: CRR / PRA PS1/26 Art. 226(1)/(2), Art. 224(2)(a) 20-day secured-lending, Art. 224 Table 4 FX 8%, Art. 122 Table 6 unrated corp SCRA Grade B 100%.
Changed¶
-
QRRE-coupling TODO closed;
_FACILITY_QRRE_COUPLED_COLUMNSconstant extracted (P6.26, batch 20260510-1300): pure non-functional refactor ofengine/hierarchy.py. Site analysis showed the two QRRE-touching sites (_undrawn_select_expressionsprojects from the facility frame;_propagate_facility_qrre_columnsjoins+coalesces against the unified loan/contingent/facility_undrawn frame post-concat) are NOT duplicating the same expression — they operate at different pipeline stages with different operations and different null semantics. A merge would either need an awkward two-mode helper or force the upstream site to do an unnecessary self-join. Closes the lone source-treeTODO(qrre-coupling)marker with an explanatory comment block and extracts the four shared column names into a module-level constant_FACILITY_QRRE_COUPLED_COLUMNS = ("is_revolving", "is_qrre_transactor", "facility_limit", "facility_termination_date"). The constant is allowlisted inscripts/arch_check.py::VALIDATION_ENUM_ALLOWLIST(engine-internal coupling marker, mirrorsengine/utils.py::NULLABLE_PARTITION_KEYSprecedent — keeping it inside the module that enforces the coupling rather than splitting todata/schemas.py).tests/contracts/test_no_raw_over_on_nullable_keys.pyline allowlist bumped 405,406 → 420,421 for the unrelated_build_rating_inheritance_lazy.over("counterparty_reference")calls (line shift only, no semantics change). Nopl.col/pl.coalesceexpressions touched at either site — the refactor is by design behaviour-preserving. Pinned bytests/unit/test_p6_26_qrre_coupling_constant.py(3 tests: constant existence with exact tuple value; TODO removal asserted on the read source; four-column propagation regression covering both Site A facility_undrawn synthesis and Site B post-concat propagation). Ref:src/rwa_calc/engine/hierarchy.py; informational anchors CRR Art. 147(5) (QRRE classification), PRA PS1/26 Art. 162(2A)(k) (facility_termination_date). -
Three docstring corrections (P1.163 + P1.168 + P1.185, batch 20260510-1200): docstring-only / no calculation impact, each pinned by a regression-guard test against the live runtime object. (a)
_pd_floor_expression(engine/irb/formulas.py:64-65) — Basel 3.1 retail mortgage PD floor0.05% → 0.10%(Art. 163(1)(b)) and QRRE transactors0.03% → 0.05%, revolvers: 0.10%(Art. 163(1)(c));CalculationConfig.basel_3_1().pd_floorsconstants were already correct. Pinned bytests/unit/irb/test_p1_163_pd_floor_docstring.py(9 tests). (b)data/tables/b31_risk_weights.pymodule docstring (line 16) andget_b31_combined_cqs_risk_weightsdocstring (line 390) — corporateCQS5: 100%→CQS5: 150%(PRA PS1/26 Art. 122(1) Table 6 retains 150% as a deviation from BCBS CRE20.42's 100%);B31_CORPORATE_RISK_WEIGHTS[5] = Decimal("1.50")constant unchanged. Pinned bytests/unit/data_tables/test_p1_168_corporate_cqs5_docstring.py(5 tests). (c)SCRAGrade.Bdocstring (domain/enums.py) — fabricated CET1/leverage thresholds replaced with the qualitative criterion of Art. 121(1)(b) ("Substantial credit risk but meets published minimum requirements (excluding buffers)"); installed via post-classSCRAGrade.B.__doc__ = "..."(mirrors EquityType.CIU precedent from P1.166). Lookup logic andB31_SCRA_RISK_WEIGHTS["B"] = Decimal("0.75")unchanged. Pinned bytests/unit/test_p1_185_scra_grade_b_docstring.py(8 tests). Ref: PRA PS1/26 Art. 163(1) / Art. 122(1) Table 6 / Art. 121(1)(b); BCBS CRE30.55 / CRE20.42 / CRE20.20. -
Stale
EquityType.CIUdocstring corrected + surfaced at runtime (P1.166, batch 20260510-0530): trailing-string docstring atdomain/enums.py:481rewritten from the stale "150% CRR SA / 250% listed or 400% unlisted B31 SA" to the correct PRA PS1/26 Art. 132(2) wording ("1,250% fallback under both CRR and B31 SA when neither look-through Art. 132A(1) nor mandate-based Art. 132A(2) approach is applied"). Python's enum machinery does not propagate per-member trailing-string docstrings intoEquityType.CIU.__doc__— that attribute returns the class docstring instead. To make the corrected text observable at runtime (and testable), a post-class assignmentEquityType.CIU.__doc__ = (...)is added immediately after theEquityTypeclass closes. Runtime constants indata/tables/{b31,crr}_equity_rw.pywere already correct (Decimal("12.50")per P1.119 / v0.1.184) — this fix is comment/audit-trail only with no calculation impact. Plan-bullet'sequity/calculator.py:21pointer was stale; that line was already correct. Pinned bytests/unit/test_p1_166_ciu_fallback_docstring.py(4 tests: 2 positive-substring presence + 2 negative-stale-text absence). Ref: PRA PS1/26 Art. 132(2); CRR Art. 132 / 132a (UK omitted by SI 2021/1078). -
Hierarchy resolver dedups duplicate org_mappings child rows + emits DQ004 (P2.24, batch 20260510-0335):
engine/hierarchy.pyadds_dedup_org_mappings(org_mappings)(called at the head of_build_counterparty_lookupafter None-handling) which materialises the mapping table once, finds duplicatechild_counterparty_referencevalues, rebuilds a deterministic single-row-per-child LazyFrame viaunique(..., keep="first", maintain_order=True), and emits oneCalculationError(code="DQ004", severity=WARNING, category=DATA_QUALITY, counterparty_reference=<child>, field_name="child_counterparty_reference")per duplicated child with a message naming bothchild_counterparty_referenceandorg_mappings. The dedup'd frame is the single source of truth fed into_build_ultimate_parent_lazy,_enrich_counterparties_with_hierarchy, andCounterpartyLookup.parent_mappings— fixes the silent row fan-out at the (formerly)hierarchy.py:491-501join (now lines 580-591). Pre-fix a child counterparty appearing twice inorg_mappings(data-quality defect) silently doubled every exposure row downstream, double-counting capital with no audit-trail signal. Post-fix the dedup is observable and operators get a DQ004 WARNING per duplicated child. Pinned bytests/unit/test_hierarchy.py::TestOrgMappingDuplicateChild(2 tests: duplicate-trigger + control arm). Contracts allowlisttests/contracts/test_no_raw_over_on_nullable_keys.pybumped 393,394 → 405,406 for the unrelated_build_rating_inheritance_lazy.over()calls (line shift only, no semantics change). Ref:contracts/errors.py:165ERROR_DUPLICATE_KEY = "DQ004"; bundle invariant of one row per(exposure_reference, exposure_type). -
COREP C 07.00 / OF 07.00 column 0020 "Exposures deducted from own funds" (P2.12, batch 20260510-0500): new column added between 0010 and 0030 in both
CRR_C07_COLUMNSandB31_C07_COLUMNS(reporting/corep/templates.py);_compute_c07_values(reporting/corep/generator.py) emits col 0020 by summing the optional input fieldown_funds_deduction_amountvia_col_sum_eager, defaulting toNonewhen the field is absent (mirrors col 0035 on-bs-netting precedent). Without this column, COREP submission validation would reject the return as a missing-dimension error. Schema-side addition ofown_funds_deduction_amounttoFACILITY_SCHEMA/LOAN_SCHEMAand the regulatory0040 = 0010 − 0020 − 0030 − 0035formula change are explicitly deferred (the current0040 = 0010 − 0030 − 0035formula is preserved). Pinned bytests/unit/test_corep.py::TestC0700Col0020(4 tests). Ref: PRA PS1/26 Annex II §C 07.00 / §OF 07.00 col 0020; CRR Art. 36/56/66 (CET1 / AT1 / T2 deductions). - Hierarchy depth-truncation HIE003 WARNING emission (P2.35, batch 20260510-0500):
_resolve_graph_eagerinengine/hierarchy.pyno longer truncates parent chains silently atmax_depth=10. The helper now returns a 4-column DataFrame with a newtruncated:Booleanflag set whendepth == max_depth AND current in parent_ofafter the inner walker exits;_build_counterparty_lookupmaterialises oneCalculationError(code=ERROR_HIERARCHY_DEPTH="HIE003", severity=WARNING, category=HIERARCHY)per truncated entity, then drops the helper column to preserveCounterpartyLookup.ultimate_parent_mappings's published 3-column schema. The deepest reachable parent is still surfaced (non-fatal) so the SA/IRB pipeline keeps running. Severity isWARNING(not the hierarchy-error factory defaultERROR) because the resolver still produces a usable parent reference. Tests pin a 12-node chainCP_DEPTH_C0..CP_DEPTH_C11: onlyC0(chain depth 11 againstmax_depth=10) emits HIE003 — chains terminating at exactly depth 10 do not. Pinned bytests/unit/test_hierarchy_max_depth.py::TestHierarchyMaxDepthTruncation(4 tests). Ref: internal hierarchy contract; CLAUDE.md § Error Handling (data-quality errors must accumulate inlist[CalculationError], never silent). - F-IRB LGD helper naming regression guard (P1.179, batch 20260510-0500): pure regression-guard pinning the existing dispatch contract —
IMPLEMENTATION_PLAN.mdbullet was stale becauseget_firb_lgd_table(is_basel_3_1=...)andget_firb_lgd_table_for_framework(is_basel_3_1=...)already dispatch correctly viaBASEL31_FIRB_SUPERVISORY_LGD/FIRB_SUPERVISORY_LGD, anddata/tables/__init__.pyre-exports all four symbols. No engine change required. New tests pin (1) CRR DataFrame default values at(unsecured, senior) = 0.45and(receivables, senior) = 0.35; (2) Basel 3.1 DataFrame values incl. theis_fsesplit (unsecured non-FSE = 0.40,unsecured FSE = 0.45,receivables = 0.20); (3) schema delta —is_fsecolumn only on B31; (4) dict helper values for both frameworks; (5) cross-helper consistency between dict and DataFrame; (6) re-export object identity throughdata/tables/__init__.py. Pinned bytests/unit/data_tables/test_p1_179_firb_lgd_naming_regression.py(6 tests, all green-on-write). Ref: CRR Art. 161; PRA PS1/26 Art. 161 / BCBS CRE32.9-12. - Art. 159(1) Pool B regression-guard test + classifier defaulted-OR semantics (P1.127, batch 20260510-0245):
engine/hierarchy.pynow passesava_amountandother_own_funds_reductionsthrough to per-exposure outputs (Art. 34/105 reductions consumed by aggregator_el_summary._el_pool_branchesfor the Pool B EL-shortfall comparison).engine/classifier.py::_build_is_defaulted_exprnow ORscp_default_status | row-level is_defaulted | beel>0so any of the three signals gates a row into Pool B / defaulted rather than only the row-level flag. Pinned bytests/acceptance/crr/test_p1_127_art_159_pool_b_ava_regression_guard.py(3 methods: per-exposure EL-shortfall sum, Pool B memo totals, defaulted double-count guards). Ref: CRR Art. 159(1); Art. 34 (additional value adjustments); Art. 105 (prudent valuation). - CRR Art. 501(2) SME-vs-infrastructure supporting-factor regression guard (P2.22, batch 20260510-0245): pins the engine's
pl.min_horizontal(SME, infra)site atengine/sa/supporting_factors.py:360to the regulatorily-correct 0.75 outcome whenever both factors apply. Under current CRR scalars (SME tier-1 = 0.7619, tier-2 = 0.85, infra = 0.75) the invariant0.75 < 0.7619 ≤ SME_blended ≤ 0.85holds, somin(...)and Art. 501(2) second-subparagraph "infra replaces SME when both qualify" are observationally identical and no capital understatement exists today. The original plan-bullet's "capital understatement" claim was therefore wrong. The test assertssupporting_factor == 0.75, anti-assertssupporting_factor != 0.7619, and pinsrwa_final = 1,125,000for a £1.5m unrated GB corporate SME infrastructure loan (CP_SME_INFRA_001/LOAN_SME_INFRA_001). Test docstring records the algebraic invariant so any future calibration change that violates the ordering will fail the test loudly and force a re-evaluation of themin_horizontalsite. No engine change required. Pinned bytests/acceptance/crr/test_scenario_crr_f_supporting_factors.py::TestP222SMEInfraOverlapSubstitution::test_p2_22_sme_infra_overlap_substitution_regression. Ref: CRR Art. 501(2) 2nd subpara, Art. 501a(1). - B3.1 SCRA-grade dispatch for unrated institution guarantor (P1.95, batch 20260510-0102): SA RWSM under PRA PS1/26 Art. 121/235 now routes the substituted exposure through the SCRA grade table when the guarantor is an unrated institution under Basel 3.1 — Grade A → 40%, A_ENHANCED → 30%, B → 75%, C → 150% (long-term) and 20/20/50/150 (short-term). Pre-fix the engine fell through to
INSTITUTION_RISK_WEIGHTS_B31_ECRA[CQS.UNRATED] = 0.40, hard-coding SCRA Grade A for every unrated institution guarantor — under-capital risk on Grade B (40 → 75) and Grade C (40 → 150 non-beneficial → borrower 0.85).data/tables/crr_risk_weights.py::build_institution_guarantor_rw_exprextended with optionalscra_grade_colkwarg (additive — CRR and rated B31 paths untouched);B31_SCRA_RISK_WEIGHTS/B31_SCRA_SHORT_TERM_RISK_WEIGHTSlazy-imported to avoid a top-level circular betweencrr_risk_weights.pyandb31_risk_weights.py. CRMengine/crm/guarantees.pyprojectsscra_gradefrom counterparty asguarantor_scra_grade; SA_build_guarantor_rw_exprpasses the kwarg + defensive null-fill in_ensure_guarantee_substitution_columns. Null SCRA grade falls back to Grade C 150% per BCBS CRE20.21 conservative classification. IRB-side call site atengine/irb/guarantee.py:268left untouched (kwarg default=None preserves rated-CQS behaviour); future scenario can wire it. Discriminating row: £1m B3.1 unrated SME corporate borrower (RW=85%) with 5y unfunded guarantee from unrated GB institution Grade B → guarantee beneficial, RWA = 1m × 0.75 = 750,000 (pre-fix 400,000); Grade C → non-beneficial, RWA = 1m × 0.85 = 850,000 (pre-fix 400,000); null grade → conservative non-beneficial 850,000. Pinned bytests/acceptance/basel31/test_p1_95_b31_unrated_inst_guarantor_scra.py(22 tests). Ref: PRA PS1/26 Art. 121(1)–(3); Art. 235; BCBS CRE20.16-21. - COREP OF 02.01 col 0030 U-TREA = col 0010 + col 0020 per Annex II §1.3.2 (P2.42, batch 20260510-0102):
_of_02_01_row(src/rwa_calc/reporting/corep/generator.py:2988-2993) now sets col 0030 (U-TREA) tomodelled_rwa + sa_rwainstead of justmodelled_rwa. Per PRA PS1/26 Annex II §1.3.2, col 0030 is the un-floored TREA = modelled (IRB / slotting / equity) RWEA + SA RWEA. Col 0040 (S-TREA) was already correct because the engine'ssa_rwais the SA-equivalent recalculation of the entire portfolio (engine/aggregator/_floor.py:130-159). Hand-calc on the existing_b31_results_with_floorhelper (4 exposures, modelled=3000, sa=3250): col 0030 = 6250 (was 3000). The now-stale companion testtest_u_trea_equals_modelledremoved; new positivetest_u_trea_is_sum_of_modelled_and_saadded. SiblingTestOF0201TotalRow.test_total_equals_credit_riskcontinues to hold post-fix (Total row uses the same helper). Docstring at_of_02_01_rowupdated to remove misleading "U-TREA = modelled RWA" wording. Out-of-scope (separate latent bug, future P-item): col 0010/0020 today sum across the whole portfolio; per Annex II col 0010 should be modelled-approach rows only and col 0020 SA rows only — that partitioning is unchanged here. Pinned bytests/unit/test_corep.py::TestOF0201CreditRiskRow::test_u_trea_is_sum_of_modelled_and_sa(9 tests in the credit-risk row class). Ref: PRA PS1/26 Art. 92(2A)/(3)/(3A); Annex II §1.3.2 OF 02.01 col 0030. ciu_holdingsregistry entry +from_registrywiring (P6.19, batch 20260510-0102): newDataSourceFile(id="ciu_holdings", relative_path=Path("equity/ciu_holdings"), requirement=RequirementLevel.OPTIONAL, description="CIU look-through holdings for Art. 132(3) equity treatment")added toDATA_SOURCESbetweenequityandspecialised_lending(src/rwa_calc/config/data_sources.py).DataSourceConfig.from_registry()(src/rwa_calc/engine/loader.py:218) now callsget_p("ciu_holdings")so the previously deadDataSourceConfig.ciu_holdings_filefield (declared atloader.py:180, consumed at_build_bundle:362) is finally populated. Pre-fix any caller usingDataSourceConfig.from_registry()would silently getciu_holdings_file=None, causing_build_bundleto short-circuit at the optional-load path and dropping CIU look-through data — leavingRawDataBundle.ciu_holdings = Noneand forcing the equity calculator to the Art. 132(2) 1,250% punitive fallback. Plumbing-only fix:RawDataBundle.ciu_holdingsfield,CIU_HOLDINGS_SCHEMA, and_build_bundlewiring already existed. Pinned bytests/unit/config/test_p6_19_data_sources_ciu_holdings.py(7 tests inTestDataSourceRegistryCiuHoldings). Ref: CRR / PRA PS1/26 Art. 132(2)–(3) CIU look-through approach.- Art. 120(2) Table 4 short-term institution guarantor risk weight (P1.122 sub-claim (c), batch 20260510-0200): SA RWSM now routes the substituted exposure to a rated institution guarantor through Art. 120(2) Table 4 (CQS 1-3 = 20%, CQS 4-5 = 50%, CQS 6 = 150%) when the borrower's
original_maturity_years ≤ 0.25(3 months). Pre-fix_build_guarantor_rw_expr(engine/sa/namespace.py:854) routed everything through long-term Table 3 — over-stating capital by 30 pp on CRR (50%→20%) and 10 pp on B3.1 ECRA (30%→20%). NewINSTITUTION_SHORT_TERM_RISK_WEIGHTS_B31_ECRAdict added todata/tables/crr_risk_weights.py(numerically identical to CRR Table 4);INSTITUTION_SHORT_TERM_RISK_WEIGHTS_CRRextended withCQS.UNRATED → 0.20(Art. 121(3)).build_institution_guarantor_rw_exprnow accepts an optionalshort_term_flag_col;apply_guarantee_substitutionderives a_inst_guarantor_short_termBoolean fromoriginal_maturity_years ≤ 0.25(matching the existing direct Art. 120(2) gate convention atnamespace.py:1072-1074). Discriminating row: £1m B3.1 corporate borrower (CQS 4 unrated 100%) with 81-day residual + 2y unfunded guarantee from CQS 2 institution → RWA drops 300,000 → 200,000 (B3.1) and 500,000 → 200,000 (CRR). Sub-claims (a) corporate CQS 3 = 75% and (b) unrated institution SCRA grades remain open. Pinned bytests/acceptance/basel31/test_p1_122_short_term_institution_guarantor_substitution.py(6 tests; 4 discriminating + 2 structural guards). Ref: PRA PS1/26 Art. 120(2) Table 4, Art. 235; CRR Art. 237(2)(a) eligibility filter respected. - B3.1 international-organisation regression test (P1.154, batch 20260510-0200): closed via regression-guard — engine implementation already correct (
ExposureClass.INTERNATIONAL_ORGANISATIONatdomain/enums.py:96; SA dispatcher atengine/sa/namespace.py:912/1094routes Art. 118 IO 0% viaIO_ZERO_RWand B3.1 Art. 117(1)(a) Table 2B non-named MDB CQS 2 = 30% viaMDB_RISK_WEIGHTS_TABLE_2B). Plan entry was stale: only the B3.1 acceptance test was missing. Newtests/acceptance/basel31/test_p1_154_b31_art_118_international_organisation_class.py(9 tests) pins both the IMF (international_organisation, 0% RW) and Black Sea Trade & Development Bank (mdb non-named, 30% RW Table 2B CQS 2) discriminator. CRR-side acceptance testtests/acceptance/crr/test_p1_154_art_118_international_organisation_class.pypre-existed. Ref: CRR Art. 112(1)(e); CRR Art. 117(1)(a)/(2), Art. 118; PRA PS1/26 Art. 117(1)(a) Table 2B, Art. 118. - P1.123 plan-entry close (silent fix already shipped in commit
e5bbdbdbatch 20260509-1726): FCCM(1+HE)exposure-side gross-up was implemented in the prior batch but the plan bullet was never ticked. No code change in this batch —tests/acceptance/crr/test_p1_123_art_223_5_fccm_exposure_volatility_haircut.py(13 tests) confirms the fix is in production. Ticked here for plan hygiene. Ref: CRR Art. 223(5). validate_aggregated_bundleregulatory output-bounds checker (P2.34, batch 20260510-0030): new public function insrc/rwa_calc/contracts/validation.pyasserting per-rowrisk_weight ≤ 12.5(CRR Art. 92(3); CRE31.5),risk_weight ≥ 0(Art. 153/CRE31),rwa_final ≥ -1e-9(Art. 92(3); float64 round-off tolerance), andead_finalnon-null at theAggregatedResultBundleboundary. Errors flow asCalculationErrorcodesOUT001-OUT004(added tocontracts/errors.py) withsample_cap=5per bound + a single summary error if the offending-row count exceeds the cap. Schema-safe: silently skips a bound if its target column is absent frombundle.results. LazyFrame-first (one.collect()per bound). Not auto-wired into the pipeline orchestrator (deferred). Pinned bytests/contracts/test_aggregated_bundle_validation.py(11 tests).CreditRiskCalc.base_currencyforwarded through factories (P6.20, batch 20260510-0030):CreditRiskCalc(..., base_currency="EUR")was a silent no-op —_create_config()(api/service.py:174-195) dropped the value before invokingCalculationConfig.crr()/.basel_3_1(), both of which hardcodedbase_currency="GBP"in theircls(...)calls (contracts/config.py:961, 1041). Both factories now accept and forward abase_currency: str = "GBP"kwarg, and the service layer passesself.base_currencythrough. Default"GBP"semantics preserved end-to-end. Pinned bytests/unit/api/test_service_base_currency.py(5 tests; 2 forwarding + 3 default-preservation).-
Loader optional-file bare except narrowed (P6.18, batch 20260510-0030):
engine/loader.py::_load_file_optionalno longer silently swallows non-FileNotFoundErrorexceptions.FileNotFoundErrorcontinues to returnNonewith only a DEBUG log (the legitimate "optional input not configured" signal). Any otherException(corrupt parquet,OSError,PermissionError,pl.exceptions.ComputeError) now appends a newCalculationError(DQ007 ERROR_OPTIONAL_FILE_UNREADABLE, severityWARNING, categoryDATA_QUALITY) ontoRawDataBundle.errorsand emits a single lazy-formattedlogger.warning(...)(per CLAUDE.md § Logging). The required-file path_load_fileis unchanged — corrupt required files still raiseDataLoadError. New error code +optional_file_load_error(...)factory added tocontracts/errors.py. Threading uses an expliciterrors: list[CalculationError]parameter wired through_build_bundleviafunctools.partial;lf.collect_schema()afterscan_fn(...)forces parquet-corruption detection thathas_rows's broad except would otherwise silently demote to "empty file". Pinned bytests/unit/test_loader_optional_error_handling.py(4 tests). -
PRA Art. 191A(2)(e)(i) funded-only look-through for two-layer credit protection (P1.161, batch 20260509-1825): when an unfunded guarantee is itself collateralised by funded collateral posted by the guarantor, the institution may now elect via the new
look_through_election="funded_only"flag onGUARANTEE_SCHEMAto suppress the guarantee for RWSM purposes and re-anchor the guarantor-posted collateral onto the original obligor exposure ahead of FCCM/FCSM allocation. New modulesrc/rwa_calc/engine/crm/look_through.py(apply_funded_only_look_through()) wired as Step 0 insideengine/crm/processor.py::get_crm_adjusted_bundle()/get_crm_unified_bundle(). Schema additions:GUARANTEE_SCHEMA.look_through_election(enumnone/funded_only/both, defaultnone) andGUARANTEE_SCHEMA.is_collateralised_by_guarantor(Boolean default False);COLLATERAL_SCHEMA.posted_by_counterparty_reference(String, optional);VALID_BENEFICIARY_TYPESextends to include"guarantee"so collateral can attach to a guarantee row. Two new audit-trail error codes:CRM007 ART_191A_2_E_LOOKTHROUGH_APPLIED(informational) andCRM008 LOOKTHROUGH_NOT_IMPLEMENTED(the deferred(2)(e)(ii)"both" election surfaces this warning and falls back tonone). Discriminating capital: a £1m GBP corporate exposure with a 100%-coverage CQS4 corporate guarantor backed by £400k of GBP cash collateral drops RWA 1,000,000 → 600,000 under election=funded_only (EAD nets to 600k × unrated corp 100% RW); election=none preserves today's RWSM-with-no-benefit behaviour at 1,000,000 (regression pin). Out-of-scope and tracked separately: Art. 191A(2)(e)(ii) "both" election; Art. 191A(2)(f) borrower-deeming flexibility; F-IRB / A-IRB look-through under PSM Art. 236 / LGD-AM Art. 183. Pinned bytests/acceptance/basel31/test_p1_161_art_191a_two_layer_protection.py(4 tests). Ref: PRA PS1/26 Art. 191A(2)(d)–(f), Art. 197(1)(a), Art. 222, Art. 223(5), Art. 235; BCBS CRE22.18 / CRE22.71. - ADC classification derived from
is_under_constructionrather than caller-supplied (P1.140, batch 20260509-1825):engine/classifier.py::_derive_independent_flagsnow derivesis_adcvia the new_build_is_adc_exprhelper rather than relying on caller pre-tagging —is_adc = (cp_entity_type IN {corporate, company, specialised_lending} AND NOT cp_is_natural_person AND (is_under_construction.fill_null(False) OR _pt_upper.is_in({DEVELOPMENT_FINANCE, CONSTRUCTION_LOAN}))). Any pre-existingis_adcvalue on the input frame is coalesced as a caller-supplied override. New schema fieldis_under_construction: ColumnSpec(pl.Boolean, default=False, required=False)on FACILITY_SCHEMA, LOAN_SCHEMA, and CONTINGENTS_SCHEMA (data/schemas.py); propagated throughengine/hierarchy.py::_coerce_loans_to_unified/_coerce_contingents_to_unifiedand the facility-undrawn select expressions. Two ancillary classifier guards prevent ADC routing being lost downstream: ADC-flagged rows are now excluded from the RE loan-splitter candidate gate (_re_split_candidate_gates) and from the CORPORATE → CORPORATE_SME reclassification (_classify_exposure_subtypes) soexposure_class == "corporate"survives to the SA branch's existing_b31_append_real_estate_branchesADC consumer. Pre-fix a £10m B3.1 corporate development-finance loan to an SPV withis_under_construction=Trueproduced RWA 4,925,000 (residential RE loan-splitting fired becauseis_adc=False); post-fix RWA = 15,000,000 = £10m × 150% Art. 124K(1) ADC RW. Natural-person obligors fall through tois_adc=Falseregardless ofis_under_construction, preserving the existing residential RE path. Out-of-scope: Art. 124K(2) qualifying-residential 100% concession (presold derivation), defaulted ADC interaction with Art. 127, Art. 124E three-property limit (P1.142), Art. 124(4) mixed-use splitting (P1.141). CRR untouched (no Art. 124K). Pinned bytests/acceptance/basel31/test_p1_140_adc_classification_derivation.py(8 tests). Ref: PRA PS1/26 Glossary "ADC exposure"; Art. 124(3); Art. 124K(1); BCBS CRE20.91. - B31 equity SA-only hard guard in classifier (P2.39, batch 20260509-1642):
engine/classifier.py::_apply_b31_approach_restrictionsnow addsb31_equity_sa_only = (exposure_class_irb == ExposureClass.EQUITY.value)to the existing sovereign-like SA-only mask so neithernew_airbnornew_firbcan fire for Basel 3.1 equity exposures, regardless of caller-suppliedIRBPermissions. Pre-fix a misconfiguredIRBPermissionsgranting AIRB toExposureClass.EQUITYwould route equity exposures throughapproach="advanced_irb"ahead of the equity branch in_build_approach_expr's decision ladder, contradicting PRA PS1/26 Art. 147A(1)(h) (Art. 155 left blank under PS1/26). Post-fix the row falls through to.when(exposure_class == EQUITY).then(EQUITY)andapproach="equity". CRR is untouched (_apply_b31_approach_restrictionsreturns early for non-B3.1 configs; legacy CRR Art. 155 IRB equity approach retained).firb_clear_expris intentionally not widened — equity is SA-only, not F-IRB-only. Pinned bytests/unit/classifier/test_p2_39_b31_equity_sa_only_guard.py(7 tests acrossTestB31EquitySaOnlyGuardandTestCrrEquityControlNoB31Guard); 48 B31-L equity acceptance tests continue to pass. Ref: PRA PS1/26 Art. 147A(1)(h) read with Art. 147(2)(e); BCBS CRE60. ValidationRequestrequiresmodel_permissionsunder IRB permission mode (P1.147, batch 20260509-1642): newpermission_mode: Literal["standardised", "irb"]field onValidationRequest(api/models.py, default"standardised");CreditRiskCalc.validate()and.calculate()now propagatepermission_modeinto the request (previously silently dropped atapi/service.py:113-118and:165-170). New_check_irb_required(...)step inDataPathValidator.validate()(api/validation.py) appendsPath("config/model_permissions.parquet")tofiles_missingand emits a newVAL003APIError(api/errors.py::create_irb_required_file_error) whenpermission_mode == "irb"and the model_permissions file is absent on disk; setsvalid=False. The existing short-circuit inCreditRiskCalc.calculate()then returnssuccess=Falsewithsummary.total_rwa = Decimal("0")andexposure_count = 0. Pre-fixcalculate()returnedsuccess=Truewithtotal_rwa = Decimal("1000000.0")(silent SA fallback for all exposures despite IRB request) — direct capital overstatement risk. The B31-M11 acceptance test (TestB31M11_NoModelPermissionsFallback) remains green because it exercisesPipelineOrchestrator.run_with_datawith an in-memoryRawDataBundle, bypassingDataPathValidator— the engine-layer silent-SA fallback is intentionally retained for in-memory callers. Pinned bytests/integration/test_p1_147_irb_requires_model_permissions.py(8 tests, 7 failing pre-fix). Ref: PRA PS1/26 Art. 147A; CRR Art. 143 / 150; internal model-permissions gating contract.- Null-safe
is_guaranteedfilter in CRM reporting / CR7 / CR7-A disclosures (P1.146, batch 20260509-1642): sink-side fix atengine/aggregator/_crm_reporting.py:138,166,212— the threepl.col("is_guaranteed")/~pl.col("is_guaranteed")filters now wrap with.fill_null(False)so Polars 3VL no longer drops rows whereis_guaranteedis null. Source-side defence-in-depth atengine/crm/guarantees.py:293— the alias is now(pl.col("guaranteed_portion").fill_null(0.0) > 0).alias("is_guaranteed"), so a nullguaranteed_portionfrom upstream cannot leak a nullis_guaranteedinto the aggregator. Pre-fix any guaranteed exposure whoseis_guaranteedarrived null (e.g. an SA row that had not flowed throughapply_guarantees, or an equity-results path that did not propagate the column) was silently dropped frompost_crm_detailed,post_crm_summary, and downstream CR7 / CR7-A Pillar III disclosures — a regulatory completeness defect. The plan-bullet line reference:260was stale; the actual alias is at:293. Pinned bytests/unit/test_p1_146_is_guaranteed_null_filter.py(4 tests; aggregator-level scenario with a hand-built 3-rowsa_resultsLazyFrameis_guaranteed=[True, False, None]→post_crm_detailed.height = 4post-fix vs3pre-fix; CORPORATEtotal_ead = 2_150_000). 5 pre-existing CRM-reporting integration tests continue to pass. Ref: CRR Art. 213-217 (CRM eligibility, origin ofis_guaranteed); CRR Art. 444 / 453(g),(j); PRA PS1/26 Annex XX / XXII (CR7 / CR7-A). - CRR receivables Art. 224 haircut removed (P1.165, batch 20260509-1530):
data/tables/haircuts.pyCOLLATERAL_HAIRCUTS["receivables"]nowDecimal("0")(was0.20, an "ad-hoc approximation" with no Art. 224 basis). Receivables are non-financial collateral per CRR Art. 199(5); the entire CRR treatment lives in Art. 230 LGD* / 1.25× OC mechanism (LGDS=35% senior per Art. 230 Table 5, no minimum threshold) — already implemented infirb_lgd.py. The pre-fix engine double-counted capital by applying both an ad-hoc 20% volatility haircut AND the Art. 230 mechanism. Comment block indata/tables/haircuts.pynow points to Art. 230 / Art. 199(5).BASEL31_COLLATERAL_HAIRCUTS["receivables"]preserved at0.40per PRA PS1/26 Art. 230(2). Capital impact: F-IRB single-loan with 800k receivables collateral / EAD=1m / LGDU=0.45 / LGDS=0.35 — blended LGD* drops from pre-fix 0.4041 (which double-counted) to the regulatorily correct 0.386 = (0.35 × 640k + 0.45 × 360k) / 1m. Pinned bytests/acceptance/crr/test_p1_165_art_230_receivables_no_volatility_haircut.py(8 tests). Bug-encoded assertions intests/unit/crr/test_crr_tables.pyandtests/unit/crm/test_crm_basel31.pyflipped accordingly. Ref: CRR Art. 224, Art. 199(5), Art. 230(1)–(2), Art. 230 Table 5. COVERED_BOND_UNRATED_DERIVATIONsplit CRR vs B31 + nested Art. 129(5)(b) bug fixed (P1.180, batch 20260509-1530):data/tables/crr_risk_weights.pynow exportsCOVERED_BOND_UNRATED_DERIVATION_CRR(4 keys per CRR Art. 129(5)(a)–(d): 0.20→0.10, 0.50→0.20, 1.00→0.50, 1.50→1.00) andCOVERED_BOND_UNRATED_DERIVATION_B31(7 keys per PRA PS1/26: adds the ECRA / SCRA-only 0.30→0.15, 0.40→0.20, 0.75→0.35; B31 0.50→0.25). The old shared 7-key dict had used the B31 (b) value0.50→0.25even under CRR config — a nested numeric bug now corrected: under CRR, an unrated covered bond whose CQS3 institution issuer carries RW 0.50 derives RW = 0.20 (Art. 129(5)(b)) instead of the pre-fix 0.25._crr_unrated_cb_rw_expr(engine/sa/namespace.py) consumes the new_CRRtable;_b31_unrated_cb_rw_exprcontinues to consumeCOVERED_BOND_UNRATED_DERIVATIONwhich now aliases_B31(back-compat preserved). Pinned bytests/unit/data_tables/test_p1_180_covered_bond_unrated_derivation_split.py(7 tests). The CRR parametrize intests/unit/test_covered_bonds.py::test_unrated_covered_bond_crr_by_institution_cqs(CQS2 / CQS3 rows) flipped 0.25→0.20 to match. Ref: CRR Art. 129(5)(a)–(d); PRA PS1/26 Art. 129(5)(a)/(aa)/(ab)/(b)/(ba)/(c)/(d).- Classifier deterministic dedup with SA precedence on conflicting model_permissions (P1.145, batch 20260509-1530):
engine/classifier.py::_resolve_model_permissionsnow applies the conservative-precedence rule when conflicting(model_id, exposure_class)AIRB+SA permission rows exist — SA wins. CRR Art. 150(1) PPU is a carve-out from IRB scope; AIRB-wins would silently expand IRB scope beyond firm permission. Implementation: row-level_sa_block_match = permission_valid & (mp_approach == ApproachType.SA.value)aggregated as.max().over("exposure_reference")and AND-NOT'd against the AIRB / FIRB / slotting.max().over()flags. Order-stability also pinned: a deterministic sort on(exposure_reference, _diagnostic_priority, mp_approach, mp_country_codes, mp_excluded_book_codes)precedesunique(subset=["exposure_reference"], keep="first", maintain_order=True), so the surviving_model_permission_diagnosticis the most-informative one (null > filter_rejected > unmatched_model_id > null_model_id) regardless of input ordering. Existing CLS006 ladder fires"filter_rejected"when SA blocks IRB. Behaviour change: operators with conflicting AIRB+SA rows in production will now see a CLS006 warning where pre-fix the engine silently routed to AIRB. Pinned bytests/unit/classifier/test_p1_145_model_permissions_dedup_determinism.py(9 tests across two physical orderings of the same 9-row fixture; both produce identical post-classifier frames). 188 related classifier / permission / model_id tests pass with no regressions. Ref: CRR Art. 143 (IRB permission scope), Art. 150(1) PPU; PRA PS1/26 Art. 150(1A). - CRR Art. 237/238/239(3) maturity mismatch on unfunded credit protection (P1.109, batch 20260509-1359):
engine/crm/guarantees.pynow scalesamount_coveredandpercentage_coveredby(t − 0.25) / (T − 0.25)when the guarantor's residual maturity is shorter than the secured exposure's residual maturity (gated onconfig.is_crr). Art. 237(2) ineligibility (residual < 3 months / original < 1 year) flows through the existing eligibility chain. Pre-fix the engine applied FX haircut but no maturity-mismatch reduction, understating capital on long exposures with short-dated guarantees: a £1m / 5y CRR corporate exposure fully covered by a 2.5y guarantee now producesGA = 1m × (2.5 − 0.25) / (5.0 − 0.25) = 473,684.21and blended RWA = 621,052.63 (vs the pre-fix 100% substitution). Pinned bytests/acceptance/crr/test_p1_109_art_237_maturity_mismatch_guarantees.py(11 tests). Ref: CRR Art. 237, Art. 238, Art. 239(3); BCBS CRE22.74. - B31 SA RWSM corporate-CQS3 guarantor RW = 75% via Art. 122(2) Table 6 (P1.110, batch 20260509-1359):
engine/sa/namespace.pyimportsB31_CORPORATE_RISK_WEIGHTSand gates the corporate-guarantor SA risk-weight lookup onis_basel_3_1so a CQS3-rated corporate guarantor on a B31 exposure now picks up Table 6's 75% instead of the CRR Art. 122 100%. CRR path untouched. Capital impact: a £1m B31 corporate exposure guaranteed by a CQS3 corporate guarantor drops post-RWSM RW 100% → 75% (RWA 1m → 750k). Pinned bytests/acceptance/basel31/test_p1_110_art_122_corporate_cqs3_guarantee_substitution.py(7 tests). Distinct from P1.95 (SCRA unrated institutions) and P1.122 (full B31 framework branching). Ref: PRA PS1/26 Art. 122(2) Table 6, Art. 235. - PSM F-IRB LGD substitution routes by guarantor seniority (P1.160, batch 20260509-1359):
engine/crm/guarantees.py::_apply_guarantee_splitsnow threadsguarantor_seniorityfrom the guarantee table through the per-guarantor pre-aggregation, the join select-list, the borrower-frame drop-list, and the no-guarantee / remainder null-fill paths — so the IRB stage receives the actual seniority value instead ofNone. The downstream IRB routing inengine/irb/guarantee.pyalready had the seniority dispatch wired (Art. 161(1)(aa) senior 0.40 / Art. 161(1)(b) subordinated 0.75 / Art. 161(1)(d) covered-bond 0.1125) but receivedNonefrom upstream and silently defaulted to the senior LGD. The same patch tightens_add_guarantee_status_columns: when the parameter-substitution path was taken (_is_pd_substitution=Trueandguaranteed_portion>0),guarantee_method_usednow resolves to"PD_PARAMETER_SUBSTITUTION"regardless of whether the beneficial gate retained the borrower RWA — theGUARANTEE_NOT_APPLIED_NON_BENEFICIALsignal continues to live onguarantee_status. Discriminating row: B31 corporate borrower (PD=0.015, M=2.5y, EAD=1m) with subordinated corporate guarantor (PD=0.005) —guarantor_rw_irb0.61877 → 1.16037 (LGD 0.40 → 0.75), guarantee correctly judged not beneficial, RWA retained at borrower 938,690 instead of incorrectly applied 618,870. Pinned bytests/acceptance/basel31/test_p1_160_art_161_psm_lgd_seniority_routing.py(11 tests);tests/unit/test_irb_double_default.py::TestDoubleDefaultRWA::test_dd_floor_at_guarantor_rwwidened to acceptPD_PARAMETER_SUBSTITUTIONas a method label for non-beneficial PSM scenarios. Ref: PRA PS1/26 Art. 161(1)(aa)/(b)/(d), Art. 236(1)(a); BCBS CRE32. - FCSM Art. 222(4) SFT 0%/10% carve-out + Art. 222(6) non-SFT gating (P1.93, batch 20260509-1252):
engine/crm/simple_method.py::compute_fcsm_columnsnow splits the previously-merged Art. 222(4)/(6) zero-RW exception into two distinct paths — SFTs with Art. 227-qualifying collateral get 0% (counterparty is a core market participant) or 10% (otherwise) per PRA PS1/26 Art. 222(4); non-SFTs keep the existing same-currency cash / 0%-RW sovereign 0% via the renamed_is_art_222_6_carveout_expr(gated on~exposure_is_sft); other rows fall back to the 20% Art. 222(3) floor. Step 5's Art. 222(6)(b) 20% sovereign-bond market-value discount is now suppressed whenqualifies_for_zero_haircut=True(the Art. 227 SFT carve-out is a flat RW substitution, not a value haircut). New Boolean columnis_core_market_participanton COUNTERPARTY_SCHEMA (default False; mirrored ascp_is_core_market_participanton HIERARCHY_OUTPUT_SCHEMA); new constantsART_222_4_CMP_RW = Decimal("0.00")andART_222_4_NON_CMP_RW = Decimal("0.10")indata/tables/crr_simple_method.py. Headline regression: an SFT non-CMP gilt repo (Run B) now produces blended RW 0.10 / RWA £100k vs the pre-fix RW 0.00 / RWA 0 (the buggy merged branch mis-fired the same-currency 0% on every SFT). Pinned bytests/acceptance/basel31/test_p1_93_art_222_4_fcsm_sft_carveout.py(11 tests across 3 runs). Ref: PRA PS1/26 Art. 222(4)/(6), Art. 227(2)/(3); BCBS CRE22.18. - IRB PSM correlation re-derivation reads guarantor row (P1.159, batch 20260509-1252):
engine/irb/guarantee.pynow lifts theguarantor_rw_irbmaterialisation out of_apply_parameter_substitutionStep 3 and into_apply_no_better_than_direct_floor's existing borrower-to-guarantor column-swap window, so the primary PSM RW and the Art. 160(4)rw_directfloor both compute their correlation withexposure_class/turnover_m/requires_fi_scalarsourced from the guarantor's row — per PRA PS1/26 Art. 236(1)(a)(i) "the correlation coefficient that would be assigned to a comparable direct exposure to the protection provider". Pre-fix the engine read those columns from the borrower row, so a corporate-borrower-with-FI-scalar (Art. 153(2)) routed through a regulated bank guarantor inflated correlation by the spurious 1.25× FI multiplier and over-statedguarantor_rw_irb. For a £1m / PD=0.0150 / M=2.5y corporate exposure with 60% bank guarantee,guarantor_rw_irbdrops 0.4007 → 0.2969, blended RW 0.7716 → 0.7093, RWA £771,594 → £709,324. Core math (_parametric_irb_risk_weight_expr,_correlation_expr_from_pd) untouched. The pre-existingtest_p1_157_psm_no_better_than_direct.pywas updated — itsEXPECTED_GUARANTOR_RW_IRBflipped 0.01346 → 0.17489 (now coincides withrw_directsince both inputs operate in the guarantor's class), and its strictpost_nbd > rw_irbweakened to>=(the max-of-two NBD floor remains asserted). Pinned bytests/acceptance/basel31/test_p1_159_art_236_psm_correlation_guarantor_class.py(15 tests). Ref: PRA PS1/26 Art. 236(1)(a)(i), Art. 153(2)/(4), Art. 160(4); BCBS CRE22.74. - UK CRR Art. 128 high-risk 150% RW gated off (P2.14, batch 20260509-1252): Art. 128 was omitted from UK onshored CRR by SI 2021/1078 reg. 6(3)(a) effective 1 Jan 2022 — there is no legal basis for the 150% HIGH_RISK risk weight under UK CRR until Basel 3.1 reintroduces Art. 128 from 1 Jan 2027.
engine/sa/namespace.py::_apply_crr_risk_weight_overridesno longer carries the.when(uc == "HIGH_RISK")branch, so HIGH_RISK rows fall through to the chain-tail residual 100% under UK CRR.engine/classifier.pyadds a CRR-only post-Batch-1_sa_classremap rewriting"high_risk"→"other"soexposure_class_for_sareflects the absence of the class under UK CRR and matches the namespace's RW behaviour; the Art. 112 priority carve-out at lines 607-618 is preserved (becomes inert under CRR after the remap, remains active under B3.1). Basel 3.1 is unchanged:_apply_b31_risk_weight_overrideskeeps the 150% branch (Art. 128 reintroduced under PS1/26). Capital impact: a £1m unrated VC/PE high-risk corporate exposure drops from RWA £1.5m to £1.0m under UK CRR (B3.1 unchanged at £1.5m). TheHIGH_RISK_RW = Decimal("1.50")table value indata/tables/crr_risk_weights.pyis retained as an unused-but-correct entry;B31_HIGH_RISK_RWis unchanged and still consumed by the B31 path. Pre-existing teststests/unit/test_high_risk_items.py::TestCRRHighRiskItemsandtests/unit/test_defaulted_secured_split.py::TestDefaultedEdgeCases::test_high_risk_unaffectedwere flipped to expect the corrected 100% under CRR; B3.1 sibling assertions and theHIGH_RISK_RWconstant tests are left untouched. Pinned bytests/acceptance/crr/test_p2_14_art_128_high_risk_uk_omitted.py(13 tests). Ref: SI 2021/1078 reg. 6(3)(a); UK CRR Art. 112(1) waterfall, Art. 122, Art. 133(2); PRA PS1/26 Art. 128 (B3.1 reintroduction). - CRR Art. 117(1) non-named MDB institution routing (P1.184, batch 20260509-1300): under CRR, non-named multilateral development bank exposures now risk-weight off the institution tables (Art. 120 Table 3 for own-CQS rated, Art. 121 Table 5 sovereign-derived for unrated) instead of the Basel-3.1 Table 2B that the engine previously consulted under both frameworks. Two-layer fix in
engine/sa/namespace.py: (1)_prepare_risk_weight_lookupcoalescescp_institution_cqsintocqsfor MDB rows so the rated CQS join target is populated (closes a separate latent bug where every MDB row arrived at the SA branch withcqs=null); (2)_apply_crr_risk_weight_overridesreplaces the prior single MDB-unrated 50% branch with two branches — rated MDB →build_institution_guarantor_rw_expr(Art. 120 Table 3) and unrated MDB → newINSTITUTION_RISK_WEIGHTS_SOVEREIGN_DERIVEDtable (Art. 121 Table 5) withINSTITUTION_RISK_WEIGHTS_CRR[CQS.UNRATED]100% fallback.MDB_RISK_WEIGHTS_TABLE_2BandMDB_UNRATED_RWremain indata/tables/crr_risk_weights.py(still used by the B31 path) with an updated docstring marking them Basel-3.1-only. Named MDBs (entity_type="mdb_named") keep the unconditional 0% under both frameworks. Capital impact: CRR rated CQS-2 MDB jumps 30%→50% (+20pp); CRR unrated MDB with sovereign CQS-1 drops 50%→20%; CRR unrated MDB with no sovereign data jumps 50%→100%. B31 unaffected. Pinned bytests/acceptance/crr/test_p1_184_art_117_mdb_institution_routing.py(14 tests). Ref: CRR Art. 117(1), Art. 120 Table 3, Art. 121 Table 5. - F-IRB purchased receivables / dilution-risk supervisory LGD (P1.151, batch 20260509-1300): CRR Art. 161(1)(e)/(f)/(g) and PRA PS1/26 Art. 161(1)(e)/(f)/(g) supervisory LGDs for purchased receivables and dilution risk now wired into the F-IRB engine. New optional nullable column
purchased_receivables_subtypeonFACILITY_SCHEMA/LOAN_SCHEMA/CONTINGENTS_SCHEMA(valuesnull/"senior"/"subordinated"/"dilution_risk", validated viaCOLUMN_VALUE_CONSTRAINTS). New keys onFIRB_SUPERVISORY_LGDandBASEL31_FIRB_SUPERVISORY_LGDindata/tables/firb_lgd.py:purchased_receivables_senior(CRR 0.45 / B3.1 0.40 — the new B3.1 senior-unsecured rate),purchased_receivables_subordinated(1.00 both frameworks),dilution_risk(CRR 0.75 / B3.1 1.00 — PS1/26 recasts the dilution-risk LGD upward).apply_firb_lgd(engine/irb/namespace.py) gains apl.when().then()dispatch that takes precedence over the seniority-based selector whenpurchased_receivables_subtypeis non-null — soseniority="senior", subtype="dilution_risk"correctly resolves to dilution LGD, not the senior 0.40.engine/hierarchy.py::_coerce_loans_to_unifiedextended to pass the new column through to the IRB stage. Capital impact under B3.1: a 1m senior PR exposure at PD=1% on a 1y residual moves from default-LGD 0.40 to subtype-LGD 0.40 (no change) but a subordinated PR at 500k jumps 0.75→1.00 (RWA 814k vs. 611k pre-fix); a 200k dilution-risk row jumps 0.40→1.00 (RWA 326k vs. 130k pre-fix). Pinned bytests/acceptance/basel31/test_p1_151_art_161_purchased_receivables_lgd.py(5 tests). Ref: CRR Art. 161(1)(e)/(f)/(g); PRA PS1/26 Art. 161(1)(e)/(f)/(g); BCBS CRE32. - UKB OV1 output-floor disclosure rows 4a / 5a–7b (P1.162, batch 20260509-1300): PRA PS1/26 Annex XX UKB OV1 mandates seven output-floor disclosure rows that the calculator was emitting as a 13-row template (jumping 4 → 5 and 24 → 26).
B31_OV1_ROWSinreporting/pillar3/templates.pynow carries 20 entries with refs4a(Total RWEAs pre-floor),5a/5b(CET1 ratio pre-floor / pre-floor transitional),6a/6b(Tier 1 ratio),7a/7b(Total capital ratio). New frozen dataclassPillar3CapitalRatioOverrides(contracts/config.py, exported viacontracts/__init__.py) carries six optionalDecimalfields letting firms supply the pre-floor and pre-floor-transitional ratios that cannot be derived from credit-risk pipeline data alone.Pillar3Generator.generate_from_lazyframenow accepts an optionalcapital_ratioskwarg and_generate_ov1now: (a) emits row 4a assum(rwa_pre_floor)over the full results LazyFrame withc = a × 0.08(None when the column is absent — same fallback posture as existing rows 26/27); (b) emits rows 5a–7b from the override (× 100 to cola, withbandcleft None to bypass the existing own-funds shim). When no override is supplied each ratio row stays mandatory in shape but value-blank. CRRCRR_OV1_ROWSis unchanged. Pinned bytests/unit/reporting/pillar3/test_p1_162_ukb_ov1_floor_rows.py(6 tests, including a CRR regression guard). Ref: PRA PS1/26 Annex XX UKB OV1, Art. 92(2A), Art. 92(5), Art. 438(d). - CRR Art. 197 / 207(2) covered-bond collateral eligibility gating (P1.96, batch 20260509-1100):
engine/crm/haircuts.py::_apply_collateral_haircutsnow treatscollateral_type=="covered_bond"rows as ineligible financial collateral on non-SFT exposures (Art. 197(1) closed list governs; covered bonds are NOT in (a)–(h)). The Art. 207(2) carve-out for repo / SFT / capital-markets-driven / secured-lending transactions keeps the existingcovered_bond → corp_bondArt. 224 supervisory-haircut routing — only the gating expression changed. The plan-bullet wording was stale ("falls through to other_physical 40%"); pre-fix the engine unconditionally applied corp-bond CQS-banded haircuts to all covered-bond collateral regardless of SFT status, understating capital on non-repo term loans secured by covered bonds. Ineligibility flows through the existing_bond_ineligiblechain (value_after_haircut=0,is_eligible_financial_collateral=False); no schema or new data tables. Pinned bytests/acceptance/crr/test_p1_96_art_197_covered_bond_eligibility.py(9 tests, paired Run A non-SFT term-loan ineligibility / Run B repo Art. 207(2) carve-out). The pre-existingtest_p1_96_covered_bond_haircut_routing.pywas reframed to setis_sft=Trueon its repo fixture so the 416,970.56 expectation now genuinely tests the carve-out instead of relying on the buggy unconditional routing. Ref: CRR / PRA PS1/26 Art. 197(1), Art. 207(2), Art. 224 Table 1. - CRR Art. 162(3)(b) F-IRB short-term trade-finance M derivation (P1.118, batch 20260509-1100):
engine/irb/namespace.py::IRBLazyFrame.prepare_columnsnow deriveshas_one_day_maturity_floor=Truefromis_short_term_trade_lc=True AND maturity_date is not null AND residual_years <= 1.0(gated onconfig.is_crr; B31 wording differs and is deferred). The flag was previously caller-supplied only — qualifying short-term self-liquidating trade-finance F-IRB exposures defaulted to M=2.5y or floored at 1y unless the caller pre-set the flag. With derivation in place, an unrated MLR documentary credit on a 9-month residual now picks up M=1/365 (the existingirb/formulas.py:705-707branch setsmaturity = 1/365literally when the flag is True; this is the documented engine semantic, not a "floor at 1/365" interpretation). For a £2m EAD / PD=0.5% F-IRB corporate, RWA drops 1,106,216 → 860,310 (≈22% capital relief). Reuses the existingis_short_term_trade_lcschema column added by P1.128; no schema change. Pinned bytests/acceptance/crr/test_p1_118_art_162_3_short_term_trade_finance_m_derivation.py(5 tests). FX-settlement / securities-settlement Art. 162(3) 2nd-sub (a)/(c)/(d) carve-outs and the B31 wording variant deferred. Ref: CRR Art. 162(3) second sub-paragraph point (b), Art. 4(1)(80). - B31 SA Art. 127(1) defaulted provision-ratio gross denominator (P1.120, batch 20260509-1100):
engine/sa/namespace.py::_apply_defaulted_risk_weightB31 branch now usesgross_outstanding = ead_gross + provision_deducted(with a fallback toead + provision_deductedfor unit-test entry points whereead_grossis absent) as the Art. 127(1) provision-coverage 100%/150% threshold denominator, instead ofead_final(post-CRM, post-provision). PRA PS1/26 Art. 127(1) wording is "the outstanding amount of the item or facility" (gross outstanding before specific credit risk adjustments and before CRM); the CRR branch's different wording — "unsecured part of the exposure value if those credit risk adjustments and deductions were not applied" — remains correctly modelled by the existingead_final + provision_deductedexpression and is left untouched. The plan-bullet direction was inverted: the bug was UNDER-stating RW (assigning 100% when 150% was correct on partially collateralised B31 defaults), so the fix INCREASES capital. For a B31 corporate defaulted exposure with outstanding=100k / provisions=8k / FCCM cash 60k, the pre-fix ratio was 8k/32k=25% (RW 100%, RWA 32,000); post-fix ratio is 8k/100k=8% (RW 150%, RWA 48,000). Pinned bytests/acceptance/basel31/test_p1_120_art_127_1_provision_ratio_denominator.py(B31-K13, 7 tests). Existingtest_scenario_b31_k_defaulted.py::TestB31K8_ProvisionDenominatorDifferenceupdated: B31 RW 100%→150%, RWA 80,000→120,000; CRR contrast unchanged. Ref: PRA PS1/26 Art. 127(1); BCBS CRE20.87–90. - PRA PS1/26 Art. 122(3) Table 6A short-term corporate ECAI risk weights (P1.103, batch 20260509-1025): corporate exposures with an issue-specific short-term ECAI rating now route through the new Table 6A (CQS1=20% / CQS2=50% / CQS3=100% / Others=150%) instead of the long-term Table 6 fallback (where CQS3 = 75% under Basel 3.1). On a £1m CQS-3 short-term-rated corporate the RW jumps 75% → 100% (RWA 750k → 1m, K 60k → 80k) — closes a documented capital understatement. New constant
B31_CORPORATE_SHORT_TERM_ECAI_RISK_WEIGHTSindata/tables/b31_risk_weights.py; new helper_b31_append_corporate_maturity_branchesinengine/sa/namespace.pyinvoked alongside the existing institution Table 4A branch (P1.105). Reuses thehas_short_term_ecaiFACILITY_SCHEMAflag and OR-aggregation infrastructure added by P1.105 — no new schema or hierarchy fields. Corporate short-term gate isoriginal_maturity_years ≤ 0.25only (Art. 121(4)/(5) trade-LC ≤ 6m extension is institution-only). SME corporates are excluded so the dedicated 85% SME path remains authoritative even when an SME has a short-term ECAI rating. CRR corporate branch unchanged (CRR Art. 122 has no Table 6A analogue). Pinned bytests/acceptance/basel31/test_p1_103_art_122_3_short_term_corporate_ecai.py(5 tests). Ref: PRA PS1/26 Art. 122(3) Table 6A; BCBS CRE20.42–49. - PRA PS1/26 Art. 121(4) SCRA short-term trade-finance extension wired into B31 institution chain (P1.128, batch 20260509-1025): an unrated Grade A institution exposure that is a documentary credit financing the movement of goods with original maturity ≤ 6 months now picks up the Table 5A short-term SCRA RW (Grade A = 20%) via the same Art. 121(4) carve-out the ECRA branch (Art. 120(2A)) already honoured. Pre-fix the SCRA short-term gate in
_b31_append_institution_maturity_brancheswas hard-coded tooriginal_mty ≤ 0.25, missing theis_short_term_trade_lc & original_mty ≤ 0.5OR-clause that the ECRA branch already had via the sharedin_st_windowexpression — a 5-month documentary credit fell through to the long-term SCRA Grade A 40%, doubling RWA (£1m drawn → 400k vs 200k). Fix replaces the SCRA gate withis_institution & is_unrated & in_st_window, reusing the helper expression already in place. Required follow-on edit inengine/hierarchy.py::_propagate_facility_qrre_columnsto add a counterparty-level OR-broadcast ofis_short_term_trade_lc(mirroring thehas_short_term_ecaiprecedent added by P1.105) so the flag propagates from facilities to drawn-loan exposure rows whenfacility_mappingsis empty. CRR helper_crr_append_institution_maturity_branchesunchanged (CRR has no Art. 121(4) trade-finance extension to its short-term unrated-institution treatment). Pinned bytests/acceptance/basel31/test_p1_128_art_121_4_scra_short_term_trade_finance.py(5 tests). Ref: PRA PS1/26 Art. 121(4); BCBS CRE20.16–21. - CRR Art. 224(2)(a) FX H_fx 20-day secured-lending default pinned by acceptance test (P1.186, batch 20260509-1025): the engine fix shipped silently in commit
44006daon 2026-05-03 —engine/crm/haircuts.pynow derives the liquidation period fromexposure_is_sft(5 / 10 / 20 days per Art. 224(2)(a)–(c)) instead of defaulting everything to 10 days — but the acceptance test pinning the regulatory behaviour was never written, leaving the plan item at[~]. This batch adds the missing fixtures and a 10-test acceptance scenario covering: 20-day default foris_sft=Falsesecured lending → ead_final ≈ 484,852.81 (H_fx,20 = 8% × √2 ≈ 11.314%, H_c,20 ≈ 2.828%); 5-day default foris_sft=True→ ead_final ≈ 442,426.41; an explicit regression guard against the pre-fix 10-day result of 460,000.00 (a strict inequality that can only be satisfied when H_fx is scaled to the 20-day period); and a directional sanity SL > SFT (20-day haircuts > 5-day → larger E* on secured-lending loan than on the SFT). Closes the residualliquidation_period as configsub-item of P6.15. Pinned bytests/acceptance/crr/test_p1_186_art_224_2_fx_haircut_secured_lending_default.py(10 tests). Ref: CRR Art. 224(2)(a), Art. 226(2), Art. 233. - PRA PS1/26 Art. 120(2B) Table 4A short-term institution ECAI risk weights (P1.105, batch 20260508-0254): institutions with an issue-specific short-term ECAI assessment now route through the new Table 4A (CQS1=20% / CQS2=50% / CQS3=100% / CQS4–5=150%) instead of the more permissive Table 4 fallback, closing a CQS-2/3 capital understatement (CQS3 jumps 20% → 100% on a £1m short-term-rated institution exposure). New optional Boolean
has_short_term_ecaionFACILITY_SCHEMA(default False); new constantB31_ECRA_SHORT_TERM_ECAI_RISK_WEIGHTSindata/tables/b31_risk_weights.py;_b31_append_institution_maturity_branchesinengine/sa/namespace.pygates Table 4A ahead of the Table 4 fallback whenis_institution & is_rated & has_short_term_ecai & in_st_window. Because the ECAI rating is a counterparty/rating-level property,engine/hierarchy.py::_propagate_facility_qrre_columnswas extended to OR-aggregatehas_short_term_ecaiacross a counterparty's facilities and broadcast to every exposure — loans withoutparent_facility_referencetherefore inherit the flag from a sibling facility row. CRR institution path is unchanged (Art. 120 has no Table 4A analogue). Pinned bytests/acceptance/basel31/test_p1_105_art_120_2b_short_term_institution_ecai.py(5 tests). Ref: PRA PS1/26 Art. 120(2B), Art. 120(3); BCBS CRE20.20. - CRR Art. 155(2)(c) IRB Simple GOVERNMENT_SUPPORTED equity now 370% (P1.164, batch 20260508-0254): Art. 155(2) is a closed three-bucket enumeration (290% exchange-traded / 190% PE-diversified / 370% all-other) with no government-supported carve-out. The previous 190% mapping for
GOVERNMENT_SUPPORTEDhad no regulatory basis and understated capital (190% < 370%).data/tables/crr_equity_rw.pyIRB_SIMPLE_EQUITY_RISK_WEIGHTS[GOVERNMENT_SUPPORTED]updated 1.90 → 3.70 with an Art. 155(2)(c) citation comment; the redundantis_government_supportedandequity_type=="government_supported"branches inengine/equity/calculator.py::_apply_equity_weights_irb_simplewere removed (the.otherwise(_IRB_RW[OTHER])fall-through now produces the correct 370%). For a £300k AIRB government-supported equity exposure, RWA increases 570,000 → 1,110,000. Basel 3.1 unaffected (Art. 155 is left blank under PS1/26; B31 routes through Art. 133(3) 250%). Pinned bytests/acceptance/crr/test_p1_164_art_155_2c_government_supported_irb_simple.py(5 tests) plus updated CRR-J14 acceptance andTestIRBSimpleEquityRiskWeights::test_government_supported_370_percentunit-table assertions. Ref: CRR Art. 155(2)(c). - CRR Art. 239(1) FCSM maturity-mismatch eligibility gate (P1.104, batch 20260508-0210):
engine/crm/simple_method.py::compute_fcsm_columns()now treats financial collateral whoseresidual_maturity_yearsis strictly less than the secured exposure's residual maturity as ineligible — the FCSM benefit is fully suppressed (binary gate, no Art. 239(2)(t-0.25)/(T-0.25)partial adjustment, which is FCCM/IRB only). Direct-/facility-/counterparty-level exposure residual maturity is coalesced before the comparison so the gate is conservative for pool-level pledges. Pre-fix the engine recognised collateral with shorter maturity than the exposure, understating capital. Pinned bytests/acceptance/crr/test_p1_104_art_239_1_fcsm_maturity_eligibility.py(8 tests; the discriminating MISMATCH row assertsfcsm_collateral_value=0,risk_weight=1.00,rwa=1,000,000). Plan-bullet citation was Art. 222(7); architect verified the actual FCSM maturity-mismatch exclusion lives at Art. 239(1) — Art. 222(7) is a definition-extension paragraph for sovereign-debt-securities scope. Ref: CRR Art. 239(1). - PSM PD floor uses guarantor exposure-class context (P1.157, batch 20260508-0210):
engine/irb/formulas.py::_pd_floor_expressionextended with optionalexposure_class_col/transactor_colkwargs (additive, backward-compatible — defaults preserve all existing call-site behaviour). Three PSM call sites inengine/irb/guarantee.py(_apply_parameter_substitution,_adjust_expected_loss,_apply_double_default) now passguarantor_exposure_classso the substituted (guarantor's) PD is floored at the floor that would apply to a direct exposure to the guarantor — Art. 160(4) "no better than direct" — and not at the borrower's class-floor. In the pinned scenario a corporate guarantor's PD is floored at corporate 0.05% (Art. 163(1)(a)) instead of the borrower's QRRE-revolver 0.10% (Art. 163(1)(c)), correctly loweringguarantor_rw_irbfrom 0.02408 to 0.01346. The NBD floor function_apply_no_better_than_direct_floorwas already in place; the prior fixture/test were degenerate (guarantor_rw_irb == RW_direct⇒ floor non-binding) and have been replaced with a binding configuration whereRW_direct(0.17489) ≫guarantor_rw_irb(0.01346) and the NBD floor lifts blended RWA from 136,636 to 233,494 (+71%). Pinned bytests/acceptance/basel31/test_p1_157_psm_no_better_than_direct.py(13 tests). Ref: CRR / PRA PS1/26 Art. 160(4), Art. 163(1)(a), Art. 161(1)(aa), Art. 236(1)(a)(i). - CRR Art. 126(2)(d) commercial-RE proportion split (P1.181, batch 20260508-0210):
engine/sa/namespace.py::_crr_append_real_estate_branchesresidual leg now blends the 50% Art. 126(2) secured RW with the counterparty's Art. 122 corporate-CQS RW per Art. 124(1) "the part of the exposure that exceeds the mortgage value", instead of stamping a flat 100% residual. The split mechanism mirrors Art. 125 RRE:secured_share = min(1, 0.50 / LTV),residual_share = 1 − secured_share, blended RW =0.50 × secured_share + counterparty_RW × residual_share. The residual lookup uses_cqs_table_lookup_expragainstCORPORATE_RISK_WEIGHTS(already imported), so the unrated default (1.00) is sourced from the data layer rather than inlined.engine/hierarchy.py::_coerce_loans_to_unifieddefensively passes through the CLASSIFIER_OUTPUT_SCHEMA RE columns (ltv,property_type,has_income_cover,is_qualifying_re,prior_charge_ltv,is_defaulted,qualifies_as_retail);_add_collateral_ltvonly stamps null defaults for columns not already present so the loan-frame values survive unification. Pre-fix, CRR CRE LTV > 50% was a binary 100% (overstatement); for an LTV-0.80 unrated-corporate CRE the new RW is 0.6875 (RWA 687,500) instead of 1.00 (RWA 1,000,000). The discriminating fixture row uses an LTV-0.80 corporate-CQS1 exposure, where a naïve "residual = constant 100%" fix would still emit RW=1.00 but the correct counterparty-RW lookup emits 0.3875 (RW=0.50×0.625 + 0.20×0.375). Basel 3.1 Art. 124H/124I path unchanged. Pinned bytests/acceptance/crr/test_p1_181_art_126_cre_proportion_split.py(7 tests). Ref: UK CRR Art. 126(2)(d), Art. 124(1), Art. 122 Table 6. - CRR Art. 137(1)–(2) Table 9 ECA / MEIP score-to-RW direct mapping (P1.100, batch 20260508-0020): unrated sovereigns with an Art. 137(1) Export Credit Agency / OECD MEIP score now route through Table 9 (0/0/20/50/100/100/100/150 % for scores 0–7) instead of falling through to the Art. 114(2) Table 1 unrated 100% bucket — closes a 5× capital overstatement for unrated sovereigns with low MEIP scores. New
eca_score: ColumnSpec(pl.Int8, required=False)on COUNTERPARTY_SCHEMA; newECA_MEIP_RISK_WEIGHTStable indata/tables/crr_risk_weights.py; new_eca_meip_rw_expr()inengine/sa/namespace.pyinjected after the Art. 114(3)/(4) domestic-currency override and before the unrated fallback. Basel 3.1 path unchanged. Pinned bytests/acceptance/crr/test_p1_100_art_137_eca_meip_sovereign.py(5 tests, scenario CRR-A14-ECA). Ref: CRR Art. 137(1)–(2), Art. 114(2) Table 1. - CRR Art. 226(1) non-daily revaluation haircut scaling (P1.101, batch 20260508-0020): collateral revalued less frequently than daily now sees its supervisory haircut scaled by
sqrt((N_R + T_m − 1) / T_m)where N_R is the revaluation frequency in business days and T_m is the liquidation period (5 / 10 / 20 per Art. 224(2)) — closes a haircut understatement for SFTs and other less-than-daily-revalued collateral. Newrevaluation_frequency_days: ColumnSpec(pl.Int32, required=False)on COLLATERAL_SCHEMA (null/1 ⇒ daily, no scaling; >1 fires Art. 226(1));engine/crm/haircuts.pymultiplies post-Art-226(2)collateral_haircutANDfx_haircutbyreval_factorafter the Art. 226(2) liquidation-period scaling. Art. 227 zero-haircut short-circuit preserved. The fix applies identically under CRR and PRA PS1/26 (PS1/26 carries Art. 226(1) forward unchanged). Pinned bytests/acceptance/crr/test_p1_101_art_226_1_non_daily_revaluation.py(6 tests, scenario CRR-D-REVAL). Ref: CRR Art. 226(1), Art. 226(2), Art. 224(2)(a)–(c). - UK CRR slotting Art. 153(5) Table 1 —
is_hvcreignored under CRR (P1.177, batch 20260508-0020): UK-onshored CRR Art. 153(5) contains only Table 1; the EU CRR Table 2 HVCRE table was not retained on onshoring (SI 2021/1078). The previous engine routedis_hvcre=TrueCRR exposures throughSLOTTING_RISK_WEIGHTS_HVCREweights (e.g. 95% Strong ≥2.5y) — a capital overstatement for UK firms.engine/slotting/namespace.py::SlottingExpr.lookup_rwandlookup_el_ratenow ignoreis_hvcreunder CRR (config.is_crr=True); all SL exposures use Table 1 weights and Table B EL rates regardless of the HVCRE flag. Basel 3.1 HVCRE handling under PS1/26 Art. 153(5) Table A is unchanged. Theis_hvcreflag is preserved on the audit trail. Existing CRR-E4/E7/E8 expected outputs patched to post-fix values (RW 0.95→0.70 / 0.70→0.50 / 0.95→0.70); 20 pre-existing unit tests that codified the buggy EU Table 2 behaviour were deleted or updated to assert HVCRE-equals-non-HVCRE under CRR. Pinned bytests/acceptance/crr/test_p1_177_art_153_5_uk_crr_no_hvcre.py(5 tests, scenario CRR-E9). Ref: UK CRR Art. 153(5) Table 1, Art. 147(8) (no HVCRE sub-type), Art. 158(6) Table B; SI 2021/1078.
[0.2.9] - 2026-05-04¶
Changed¶
- Entity-class mapping dicts hoisted to
data/tables/(commit908e88fd): regulatory entity-class string lists moved out ofengine/per the data/engine separation rule enforced byscripts/arch_check.pychecks 5 & 6. Behaviour-preserving. ExposureClassifierdecomposed into orchestrator +_assign_approachhelper (commit975e56f2):engine/classifier.pysplit for readability; the orchestrator now delegates the approach-decision ladder to a dedicated helper. Behaviour-preserving.- Loader
_build_bundlededuplication +LoaderProtocolconformance pinned (commitfc0ca133):engine/loader.pycollapses the per-table duplication that had drifted across the optional inputs and adds a contract test pinningLoaderto its protocol surface.
[0.2.8] - 2026-05-04¶
Changed¶
- Mixed RRE+CRE collateral now split across both classes per regime (
engine/re_splitter.py,engine/classifier.py): a single SA exposure secured by both residential and commercial property collateral now produces three child rows (secured_rre+secured_cre+residual) sharing onesplit_parent_id, instead of the prior dominance-rule single-class split that silently dropped the non-dominant collateral value. The new behaviour follows PRA PS1/26 Art. 124(4) pro-rata by collateral value under Basel 3.1 (closes documented gap D3.59 indocs/specifications/basel31/sa-risk-weights.md:1314-1321) and CRR Art. 124(1) "any part of an exposure" RRE-first sequential allocation under CRR (RRE consumes EAD up to its 80% LTV cap, then CRE picks up the remainder up to its 50% LTV cap when rental coverage is met). Per-component classifier columns added (re_split_residential_value,re_split_commercial_value,re_split_residential_eligible,re_split_commercial_eligible); per-component audit columns added tore_split_audit(rre_secured_ead,cre_secured_ead,is_mixed); new informational warning RE003 counts mixed-collateral splits per batch with the regime-specific allocation rule named in the message. Single-component splits (pure RRE or pure CRE) keep the legacysecuredrole and_secreference suffix for backward compatibility — only mixed splits usesecured_rre/secured_creand_rre/_cresuffixes. Singleprior_charge_ltvcolumn is applied to both component caps as a v1 conservatism (documented limitation). Surfaced and fixed a pre-existing latent SA dispatch bug inengine/sa/namespace.py:COMMERCIAL_MORTGAGEexposure-class rows were mis-routed through the residential RW branch because both classes contain theMORTGAGEsubstring; commercial branch now dispatches first under both CRR and Basel 3.1 paths and the Art. 127(3) defaulted RESI flat-100% rule excludes commercial RE. Ref: PRA PS1/26 Art. 124(4), Art. 124F, Art. 124H(1)-(3); CRR Art. 124(1), Art. 125, Art. 126.
[0.2.7] - 2026-05-04¶
Changed¶
- P1.97 — B31 slotting non-HVCRE column-A/C subgrade (commit
f22e3fcc):engine/slotting/namespace.pyroutes residual maturity ≥ 2.5y rows through column-A subgrade and < 2.5y through column-C per PRA PS1/26 Art. 153(5)(d). - P1.98 — subordinated corporate A-IRB LGD 25% floor (commit
836100ee):engine/irb/namespace.pyapplies the new Art. 161(5) sub-LGD 25% floor on subordinated corporate A-IRB exposures. - P1.99 — CRR Art. 120(2) Table 4 short-term institution RW (commit
3fb634c0):engine/sa/namespace.pyroutes original-maturity ≤ 3m rated institution exposures through Table 4 (20 / 50 / 100 / 150 by CQS) instead of long-term Table 3. - P1.106 — FCSM B31 ECRA institution CQS 2 collateral RW 30% (commit
8e919d40):engine/crm/simple_method.py+data/tables/b31_risk_weights.pyroute CQS 2 institution-issued financial collateral through 30% under FCSM per Art. 120 Table 3. - P1.107 — FCSM B31 corporate CQS 3 collateral RW 75% (commit
22999994): same FCSM path, corporate CQS 3 → 75% per Art. 122(2) Table 6. - P1.112 — non-UK PSE / RGLA sovereign-derived RW (commit
fc2859d0):engine/sa/namespace.pyroutes non-UK PSE / RGLA exposures through the Art. 115 / 116 sovereign-derived table when the sovereign CQS is supplied. Test pin added in68bdeafd. - P1.114 — null-safe
model_permissionsfilters (commit18e082e8):engine/classifier.pyfill_null(False)on the AIRB / FIRB permission masks so Polars 3VL nulls cannot silently route an exposure to the default approach. - P1.117 — B31 HVCRE short-maturity slotting subgrades (commit
9501c90d):engine/slotting/namespace.pyapplies the Art. 153(5)(d) subgrade-A weights on residual ≥ 2.5y HVCRE rows. - P1.121 — CRR Art. 121(3) unrated institution short-term 20% RW (commit
cf3a54e0):engine/sa/namespace.pycarves out the original ≤ 3m unrated-institution branch from the default 100% to 20%. - P1.124 — CRR Art. 237(2)(a) guarantee maturity ineligibility (commit
b2de0225):engine/crm/guarantees.pydrops guarantees with residual maturity < 3 months or original maturity < 1 year from eligibility per Art. 237(2)(a). - P1.125 — classifier FSE-column-missing CLS007 under B31 (commit
5cdba52b):engine/classifier.pyemits a CLS007 warning when theis_fsecolumn is absent on B31 input frames per Art. 147A(1)(e). - P1.126 — large-corp F-IRB scope + null-revenue conservative + CLS008 (commits
b225ccd2+272fd3d0):engine/classifier.pytreats counterparties with nulltotal_assets_euras conservative-large per Art. 147A(1)(d), emits CLS008, and gates the large-corp F-IRB restriction to corporate counterparties only. - P1.144 — IRB
calculate_expected_losspinned toead_final(commit32d91f75):engine/irb/formulas.pycomputes EL against the post-CRMead_finalrather thanead_pre_crm, per Art. 158 / 159. - P1.156 — PSM guarantor LGD seniority / FSE-aware (commit
79616bfe):engine/irb/guarantee.pyselects the guarantor LGD using the guarantor's seniority + FSE flag per Art. 161 / Art. 236(1)(a). Follow-up P1.160 in 0.2.10 wires the column from the input schema. - P1.158 — null collateral maturity uses longest band (commit
fe167f66):engine/crm/haircuts.pydefaults a nullresidual_maturity_yearson collateral to the longest haircut band (conservative). - P1.169 — B31 ECRA short-term institution CQS 4-5 = 50% (commit
3f0c2461):engine/sa/namespace.pycorrects the short-term ECRA branch from 100% to 50% per Art. 120(2A) Table 4. - P1.182 — B31 PE/VC unlisted+<5y higher-risk equity (commit
a342b27b):engine/equity/calculator.pyroutes unlisted PE/VC exposures held < 5 years through the higher-risk equity bucket per PS1/26 Glossary p.5. - P1.187 — FCSM CRR CQS 5 = 150% scalar fix (commit
88f177fd):data/tables/crr_risk_weights.pycorrects the FCSM CRR CQS 5 lookup from the buggy 100% to the regulatorily-correct 150%. - Style pass on engine/sa, crm, classifier, slotting + tests (commit
d335a695): pure formatting / import-sort cleanup across the May 3 P-coded items; no behaviour change.
[0.2.6] - 2026-05-03¶
Added¶
crm_collateral_method/airb_collateral_methodconfig knobs documented (docs/api/configuration.md): theCRMCollateralMethod(COMPREHENSIVE/SIMPLE) andAIRBCollateralMethod(LGD_MODELLING/FOUNDATION) enums onCalculationConfigwere exposed in source but absent from the API docs — practitioners had to readdomain/enums.pyto discover the toggles. Both knobs are now on the configuration page with field-summary tables, member-by-member regulatory citations (CRR Art. 222 FCSM / Art. 223–224 FCCM; PRA PS1/26 Art. 191A firm-wide election; PS1/26 Art. 169A/169B and CRR Art. 229–231 for the A-IRB collateral switch), factory defaults, framework-applicability admonitions, and workeddataclasses.replacesnippets. Closes DOCS_IMPLEMENTATION_PLAN.md D3.52.- IRB guarantee parameter-substitution path (PSM, CRE22.70–85) documented (
docs/specifications/basel31/credit-risk-mitigation.md): the four-step PSM path implemented atengine/irb/guarantee.pywas an opaque code surface — the B31 CRM spec covered guarantee haircuts and eligibility but never showed how PD substitution, F-IRB LGD substitution by guarantor seniority, correlation re-derivation, and Art. 236A maturity adjustment compose into the final risk weight. The restructured## IRB Parameter Substitutionsection now walks each step against PRA PS1/26 Art. 161 / Art. 162 / Art. 202 / Art. 235 / Art. 236(1)(a)(i) (with BCBS CRE22.72–80 in parentheses), adds the composing IRB risk-weight and EL formulas, the CRR-only Art. 153(3) double-default overlay, and an audit-trail table mapping every output column emitted by_add_guarantee_status_columns. Three Art. 236 code defects surfaced during the doc walk (Step-3 borrower-vs-guarantor correlation, Step-2 senior-unsecured LGD scalar, missing option-(i) borrower-unprotected LGD source) routed to IMPLEMENTATION_PLAN.md as P1.159 / P1.160 / P2.43. Closes DOCS_IMPLEMENTATION_PLAN.md D3.56. life_ins_collateral_value/life_ins_secured_rwoutput columns documented (docs/data-model/output-schemas.md): new "CRM — life insurance collateral (Art. 232)" subsection describes the two exposure-frame columns produced byengine/crm/life_insurance.py::compute_life_insurance_columnsand consumed bylf.sa.apply_life_insurance_rw_mapping()during SA risk-weight blending. Includes the Art. 232(3) insurer-RW mapping table (PS1/26 7-tier 20% / 30% / 50% / 65% / 100% / 135% / 150% vs CRR 4-tier 20% / 50% / 100% / 150%), defaults when no life-insurance collateral is present, IRB-side LGD_S = 40% cross-reference, and a single-source-of-truth pointer to the Basel 3.1 CRM spec. Ref: PRA PS1/26 Art. 232, Art. 200(b), Art. 212(2); CRR Art. 232. Closes DOCS_IMPLEMENTATION_PLAN.md D3.53.- IRB Risk Parameter Estimation Standards (PS1/26 Art. 179–184) documented (
docs/specifications/basel31/irb-approach.md): previously the spec stub-cited Art. 179–184 with no content, leaving implementers without a documented contract for what the calculator's PD / LGD / EAD inputs must represent. New "Risk Parameter Estimation Standards" section now covers Art. 179 (general estimation, MoC, pooled data), Art. 180(1)(a)–(h) corporate / institution PD plus Art. 180(2)(a)–(f) retail PD with the 5-year minimum data history, Art. 181 LGD (downturn LGD, LGD-in-default, 5y → 7y data ramp), Art. 181A–C downturn nature/severity/duration (incl. ≥ 20-year time-span at Art. 181C(1)), Art. 182 EAD/CCF, Art. 183 LGD-AM under A-IRB, and Art. 184 purchased receivables — all with verbatim PS1/26 Appendix 1 page citations (pp. 131–141). Closes DOCS_IMPLEMENTATION_PLAN.md D3.49. enable_double_defaultconfig knob documented (docs/api/configuration.md): the CRR Art. 153(3) double-default RW formula is now a discoverable knob from the API docs alone — field name, type, default (False), formula reference, Art. 202 / 217 eligibility, the PS1/26 B31-removal note, and a workedCalculationConfig.crr(enable_double_default=True)snippet are all in place. Practitioners no longer have to read source to find the toggle. Closes DOCS_IMPLEMENTATION_PLAN.md D3.50.- Five missing error codes documented in the contracts API reference (
docs/api/contracts.md):CLS004(ERROR_QRRE_COLUMNS_MISSING),CLS005(ERROR_RETAIL_POOL_MGMT_MISSING),IRB006(ERROR_MISSING_EXPECTED_LOSS),SA005(ERROR_EQUITY_IN_MAIN_TABLE), andSF001(ERROR_SME_MISSING_COUNTERPARTY_REF) were defined insrc/rwa_calc/contracts/errors.pybut absent from the published Error Code Constants table.SF001introduces a new "Supporting Factors" prefix. Closes DOCS_IMPLEMENTATION_PLAN.md D3.55. use_investment_grade_assessmentconfig knob documented (docs/api/configuration.md): the PRA PS1/26 Art. 122(6)/(8) IG=65% / non-IG=135% election for unrated non-SME corporates is now a discoverable knob from the API docs — field name, type, default (False), the Basel-3.1-only scope (CRR factory does not expose it), the Art. 122(7) sound-processes obligation and Art. 122(8)(b) PRA notification requirement on adoption and cessation, the Art. 92(2A) S-TREA interaction, and a workedCalculationConfig.basel_3_1(use_investment_grade_assessment=True)snippet are all in place. Thebasel_3_1()factory signature in the same page is updated to surface the argument. Closes DOCS_IMPLEMENTATION_PLAN.md D3.51 (plan-item article citation corrected — the field is Art. 122(6)/(8), not Art. 153(3) as the plan text said).
Changed¶
supporting_factor_appliedcolumn documented in canonical name (docs/data-model/output-schemas.md): the SA supporting-factor stage atengine/sa/supporting_factors.pyand the aggregator atengine/aggregator/_supporting_factors.pyemit a genericsupporting_factor_appliedBoolean covering both Art. 501 (SME, blended at the EUR 2.5m / GBP 2.2m threshold) and Art. 501a (infrastructure, flat 0.75) supporting factors, but the schema docs still showed the legacysme_supporting_factor_appliedname from before the infrastructure factor existed. The "Supporting factors (CRR only)" sub-table now lists all four pipeline-emitted columns (supporting_factor,supporting_factor_applied,rwa_pre_factor,rwa_post_factor) with broadened prose covering both factors, and a rename callout explains thatsme_supporting_factor_appliedsurvives inCRR_OUTPUT_SCHEMA_ADDITIONSonly as a legacy COREP alias. Closes DOCS_IMPLEMENTATION_PLAN.md D3.54.
Fixed¶
- IRB maturity-adjustment formula now honours the CRR Art. 162(3) carve-out from the 1-year M floor (
engine/irb/formulas.py::_maturity_adjustment_expr_from_pd): under CRR Art. 162(3) (mirrored by PRA PS1/26 / BCBS CRE32.50), four transaction types are exempt from the 1-year M floor in the IRB maturity-adjustment formula and may use M down to 1 day — daily-margined SFTs (repo / securities lending), daily-margined derivatives, margin-lending transactions, and short-term self-liquidating trade transactions (e.g. import/export LCs). The repo already had the upstream plumbing to setmaturity = 1/365for these rows (priority chain inengine/irb/namespace.py::IRBLazyFrame.prepare_columnslines 243-318, gated on thehas_one_day_maturity_floorboolean column) but the formula itself re-applied a hardcodedclip(1.0, 5.0)tomaturityinside_maturity_adjustment_expr_from_pd, silently undoing the carve-out: a contingent withis_short_term_trade_lc=Trueandeffective_maturity=0.1showedmaturity = 0.1in the output butmaturity_adjustment = 1.0andrwaidentical to a 1-year exposure — zero capital relief despite the regulatory carve-out being in scope. The fix gates the 1-year floor on thehas_one_day_maturity_floorcolumn: when True, the floor is suppressed and the actual maturity flows through; when False/null/missing, the existing[1.0, 5.0]clip applies (so ordinary corporate IRB exposures see no behaviour change). The 5-year cap from Art. 162(2) remains unconditional (no carve-out). Worked numbers at PD=0.5%, M=0.1, LGD=45%, EAD=£1m: old behaviour MA=1.0 → RWA=£521,650; new behaviour MA=0.799 → RWA=£416,969 — a 20% relief. Magnitude of relief scales with PD via the b coefficient (≈15-25% across the realistic PD range for non-defaulted exposures). The wrong behaviour was previously pinned bytests/unit/irb/test_irb_formulas.py::test_ma_below_floor_clippedandtests/unit/crr/test_crr_irb.py::test_maturity_floorwhich asserted that M=0.5 produced the same MA as M=1.0 — both replaced with two-case tests covering with-flag and without-flag behaviour. Scalarcalculate_maturity_adjustmentgains ahas_one_day_maturity_floor: bool = Falseparameter (kwarg-only by signature ordering); positional callers (which all use(pd, maturity)) are unaffected. Column visibility threaded through every vectorised call site —apply_irb_formulas,_parametric_irb_risk_weight_expr,IRBLazyFrame.prepare_columns,IRBLazyFrame.calculate_maturity_adjustment,IRBLazyFrame.apply_all_formulas,engine/irb/guarantee.py::apply_guarantee_substitution— each default-addshas_one_day_maturity_floor=Falsewhen missing, so existing fixtures and inputs that do not set the flag continue to work. New regression coverage intests/contracts/test_one_day_maturity_floor_propagation.pypins (a) schema declaration on FACILITY_SCHEMA / LOAN_SCHEMA / CONTINGENTS_SCHEMA, (b)prepare_columnsflag preservation and default-add, (c) end-to-end MA<1.0 with carve-out under both CRR and B31, (d) MA=1.0 without carve-out, and (e) bounded RWA relief at 10%-50%. Follow-up tracked: full agent-drivenB31-IRB-MAT-CARVEOUT/CRR-IRB-MAT-CARVEOUTacceptance scenarios with golden outputs covering all four trigger types (currently the contract suite covers the formula behaviour and end-to-end pipeline throughapply_all_formulas, but not the loader → hierarchy → classifier → CRM → IRB → aggregator full pipeline with pre-baked fixtures). Auto-derivation ofhas_one_day_maturity_floorfromis_short_term_trade_lcdeliberately deferred — firms must currently set the carve-out flag explicitly, and the engine treats it as the single regulatory switch into Art. 162(3). Full suite: 5,566 passed; arch_check clean. Ref: CRR Art. 153(1)(iii), Art. 162(2), Art. 162(3); PRA PS1/26 (mirrored); BCBS CRE32.46, CRE32.50. - FX collateral haircut now uses 20-day secured-lending default per CRR Art. 224(2)(a) / PS1/26 Art. 224(2)(a) (
engine/crm/processor.py::_build_exposure_lookups+_join_collateral_to_lookups,engine/crm/haircuts.py::apply_haircuts): the FX collateral haircut Hfx previously defaulted to a 10-day liquidation period (8%) for all exposures regardless of transaction type, when the regulatory baseline for secured lending (a vanilla loan facility plus collateral) is 20 business days under CRR Art. 224(2)(a) — giving Hfx = 8% × √(20/10) = 11.314% after the Art. 226(2) square-root-of-time scaling. The constantsLIQUIDATION_PERIOD_REPO=5 / _CAPITAL_MARKET=10 / _SECURED_LENDING=20already lived indata/tables/haircuts.py:146-148but were unused —is_sftfrom the exposure schemas was not propagated onto collateral. Worked example for the FX-mismatch scenario that motivated the fix: £1m GBP loan facility secured by €500k EUR cash collateral. Old behaviour: adjusted collateral = 500k × (1 - 0.08) = £460,000, EAD = £540,000, RWA = £540,000 (8% Hfx — wrong period). New behaviour: adjusted collateral = 500k × (1 - 0.11314) = £443,431.46, EAD = £556,568.54, RWA = £556,568.54 — a 3.07% RWA understatement corrected on every FX-mismatched secured-lending exposure. The fix has two layers: (1)_build_exposure_lookups()now capturesis_sftfrom each exposure level (direct/facility/cp), and_join_collateral_to_lookups()resolves them into a singleexposure_is_sftBoolean column on collateral; (2) the unconditionalfill_null(10)inapply_haircutsis replaced with: explicit per-collateralliquidation_period_daysoverride →LIQUIDATION_PERIOD_REPO(5) whenexposure_is_sft=True→LIQUIDATION_PERIOD_SECURED_LENDING(20) otherwise. Guarantees deliberately untouched — Art. 233(4) fixes guarantee Hfx at the 10-day liquidation period regardless of the underlying transaction type, so the flat 8% inengine/crm/guarantees.pyremains correct. Acceptance impact: CRR-D2 / CRR-D3 / CRR-D6 and B31-D2 / B31-D3 / B31-D6 expected RWAs re-baselined upward to reflect the 20-day default (the collateral asset haircut Hc also scales by the same factor — gilt 0.5% → 0.707%, FTSE-100 equity 15% → 21.213%); golden JSON updated undertests/expected_outputs/{crr,basel31}/. 32 unit tests pinned the buggy 10-day default by omission — all updated either with explicitliquidation_period_days=10overrides (when the test was probing haircut-lookup behaviour, not period scaling) or with re-baselined expectations citing P1.186 (when the test was specifically about the default contract). New regression tests intests/unit/crm/test_collateral_fx_mismatch.py::TestP1186DefaultLiquidationPeriodpin both the 20-day secured-lending default (fx_haircut == 0.113137) and the 5-day SFT default (fx_haircut == 0.056569) end-to-end through the processor join. Closes theliquidation_period as configoutstanding item from P6.15 / D2.39. Full suite: 5,566 passed (23 skipped, 11 deselected); arch_check, ruff, ty all clean. Ref: CRR Art. 224(2)(a)–(c), Art. 226(2); PRA PS1/26 Art. 224(2)(a)–(c), Art. 226(2); Art. 233(4) (guarantee carve-out preserved). (P1.186.) - Facility undrawn now nets netting-flagged negative drawn balances per CRR Art. 195/219 / PS1/26 Art. 195/219 (
engine/hierarchy.py::_aggregate_loan_drawn_per_facility,_per_sub_drawninner helper of MOF undrawn waterfall): the per-facility drawn aggregation previously applied.clip(lower_bound=0.0)per row before summing, so a deposit booked as a negative-drawn loan under an on-balance-sheet netting agreement contributed 0 to facility utilisation instead of offsetting positive siblings. Worked example for the user request that motivated the fix: Fac_01 limit £100m with Loan_01 £60m, Loan_02 £60m, and Loan_03 -£40m carryinghas_netting_agreement=True. Old behaviour: total_drawn = 120m → undrawn = max(0, 100-120) = 0m (the facility undrawn row was entirely suppressed by the existingundrawn_amount > 0filter). New behaviour: total_drawn = 80m → undrawn = 20m, matching the regulatorily-correct net headroom. The aggregation now uses a netting-aware expression — positives always sum normally; negatives contribute only when the loan carrieshas_netting_agreement=True. Negatives without the flag remain clipped to 0 (data-quality guard, preserving the historical contract verified by the existingtest_negative_drawn_amount_treated_as_zero,test_mixed_positive_negative_drawn_amounts, andtest_all_negative_drawn_amountstests). The same netting-aware logic is applied to the_per_sub_drawnhelper used by the MOF sub-facility undrawn waterfall so a netting-flagged deposit mapped to a sub-facility offsets that sub's utilisation rather than the parent's. The downstream CRMgenerate_netting_collateralstage (engine/crm/collateral.py) was already correct — it generates synthetic cash collateral pro-rata across positive siblings under the netting facility — and is unchanged; this fix is strictly upstream at facility utilisation. Defensive fallback: when the loans frame lackshas_netting_agreement(direct unit-test callers), the original clip-at-0 behaviour is used. Schema columnhas_netting_agreement(defaultFalse) was already present onLOAN_SCHEMA. New unit tests intests/unit/test_hierarchy.py:test_netting_negative_drawn_offsets_facility_utilisation(the user's exact 60+60-40 scenario assertingundrawn_amount=20m) andtest_negative_drawn_without_netting_flag_still_clipped(regression — negative without flag still suppresses the undrawn row). Out of scope (tracked as follow-ups, agreed with user): pro-rata vs first-positive netting-collateral allocation policy (current pro-rata is defensible per CRR Art. 219 silence on allocation order); contingent-side parallel clip in_aggregate_contingent_per_facility(negative ONB contingents under a netting agreement is unusual). Full suite: 5,554 passed (2 skipped, 4 deselected); arch_check, ruff, ty all clean. Ref: CRR Art. 195 (recognition), Art. 219 (treatment as cash collateral), Art. 228(1) SA / Art. 228(2) FIRB; PRA PS1/26 Art. 195, Art. 219(1) (new unified EAD-reduction formula), Art. 228(1) — verified by direct extraction fromdocs/assets/crr.pdfp.191/211 anddocs/assets/ps126app1.pdfp.170/190.
Cross-references¶
- Art. 179–184 estimation standards cross-link added (
docs/appendix/regulatory-references.md): the bare Art. 178–180 / Art. 181 rows in the IRB Approach articles table are replaced with cross-link rows pointing at the existing verbatim Art. 179–184 spec section inbasel31/irb-approach.mdand the Art. 181A–C economic-downturn anchor — implementers can now navigate the appendix index straight into the PD/LGD/EAD estimation rules without scanning the IRB spec page. Companion to D3.49 (above). - Art. 159(3) two-branch rule and Art. 62(d) T2 cap formula documented (
docs/specifications/crr/provisions.md,docs/specifications/basel31/provisions.md): both provisions specs previously described the EL-vs-provisions comparison at high level only — the formal A/B/C/D pseudocode block, the explicitT2_credit_cap = 0.006 × IRB_credit_risk_RWAformula, and the per-branch CET1-deduction-vs-T2-credit treatment were absent from both pages. Both specs now carry verbatim Art. 159(3) and Art. 62(d) quotes (CRR + PS1/26 App 1 p. 109), a dedicated### Art. 62(d) — T2 Cap on EL Excesssubsection, and three worked numeric examples (combined-shortfall, combined-excess-cap-binds, split-branch). The B31 spec adds a CRR↔B31 framework-delta callout cross-linking the OF-ADJ T2 component caps inoutput-floor.md(single source of truth, no duplication). Plan-item misattribution corrected inline: D4.87(b) cited "0.6% IRB RWA (CRR) / 1.25% S-TREA (B31)" — the verbatim Art. 62(d) cap base is 0.6% of IRB credit-risk RWA under both CRR and Basel 3.1; the 1.25% S-TREA figure is the GCRA cap under Art. 92(2A), not an EL-excess T2 cap. Closes DOCS_IMPLEMENTATION_PLAN.md D4.87. Ref: CRR Art. 159(3), Art. 62(d); PRA PS1/26 Art. 159(3), Art. 62(d), Art. 92(2A). - Factory-override worked examples added for
CalculationConfig.crr()/.basel_3_1()(docs/api/configuration.md): the page previously showed only the factory defaults, leaving practitioners to read source to discover which keyword overrides exist. The factory signatures now matchsrc/rwa_calc/contracts/config.py:894-1041; a new "Factory Overrides — Worked Examples" subsection adds a keyword-coverage table cross-linking each override to its per-knob anchor, plus one CRR worked example (overridingenable_double_default,crm_collateral_method,eur_gbp_rate,log_format) and one Basel 3.1 worked example (overridinguse_investment_grade_assessment,airb_collateral_method,crm_collateral_method,institution_type,reporting_basis,skip_transitional_floor). Two stale prose blocks corrected: both factories DO exposecrm_collateral_methodas a keyword, and.basel_3_1()exposesairb_collateral_method(defaultAIRBCollateralMethod.LGD_MODELLING). Plan-item enum correction: D4.89 citedAIRBCollateralMethod.EFFECTIVE_LGD— actual enum members areFOUNDATIONandLGD_MODELLING. Closes DOCS_IMPLEMENTATION_PLAN.md D4.89. SlottingCategoryenum and subgrade A/B/C/D relationship surfaced at glossary and user-guide level (docs/specifications/glossary.md,docs/user-guide/methodology/specialised-lending.md): previously the relationship between the coarse 5-bucketSlottingCategoryenum (STRONG / GOOD / SATISFACTORY / WEAK / DEFAULT) and the four subgrade columns A/B/C/D in PS1/26 Art. 153(5) Table A / Art. 158(6) Table B was documented only inside the slotting spec — practitioners reading the glossary or user guide had no entry point. The glossary now carries a newSlottingCategoryrow in the top-of-page table and a### SlottingCategory and subgrades A/B/C/Dsubsection that names the five enum members verbatim and explains that subgrades arise only on the STRONG and GOOD buckets per Art. 153(5)(c)–(f). The specialised-lending user guide gains a new## From Category to Risk Weight: the Subgrade Stepsection walking through "I have a Strong CRE exposure → RW" in four steps using the actual loader fields (slotting_category,is_hvcre,residual_maturity_years,is_short_maturity,sl_type); no risk-weight numbers are duplicated — all values cross-link to the canonical slotting spec. Plan-item correction: D4.90 cited aslotting_subgradeloader field that does not exist indata/schemas.py— the subgrade is derived fromis_short_maturity/residual_maturity_yearsper Art. 153(5)(c)–(f); onlyslotting_categoryandis_hvcreare direct inputs. Closes DOCS_IMPLEMENTATION_PLAN.md D4.90. Ref: PRA PS1/26 Art. 153(5)(c)–(f), Art. 158(6) Table B; CRR Art. 153(5) Table 1;domain/enums.py.- Art. 129(6) pre-2007 covered bond grandfathering documented (
docs/specifications/crr/sa-risk-weights.md): the CRR Art. 129(6) carve-out exempting covered bonds issued before 31 Dec 2007 from the Art. 129(1)/(3) eligibility requirements (grandfathered to maturity) was absent from the spec — practitioners had no documented basis for why pre-2007 issues retain the preferential covered-bond RW table without satisfying the modern collateral-pool / disclosure tests. New "Pre-2007 Grandfathering (Art. 129(6))" subsection now sits between the existing Art. 129 eligibility block and the B31 covered-bond changes, with verbatim CRR Art. 129(6) and PS1/26 Art. 129(6) quotes, an operational note that Art. 129(7) disclosure obligations still apply, and a B31 delta callout flagging the PS1/26 tightening (PS1/26 explicitly conditions grandfathering on Art. 129(7) compliance). Closes DOCS_IMPLEMENTATION_PLAN.md D4.47. Ref: CRR Art. 129(6), PRA PS1/26 Art. 129(6). - Art. 227(2)(d) 4-business-day close-out window for FCSM SFTs documented (
docs/specifications/crr/credit-risk-mitigation.md): the Financial Collateral Simple Method gates the 0% / 10% repo-style transaction floor on a set of preconditions in Art. 227(2)(a)–(h), one of which (the 4-business-day close-out period at Art. 227(2)(d)) was completely absent from the CRM spec — implementers had no documented eligibility test for when an SFT qualifies for the FCSM carve-out vs. falls back to the Art. 222(3) 20% RW floor. New "Art. 227(2)(a)–(h) — Preconditions for the FCSM SFT Carve-Out" subsection lists all eight gating conditions with a dedicated "Art. 227(2)(d) — 4-business-day close-out window" sub-subsection (verbatim CRR quote, framed as eligibility precondition, fall-back behaviour explained, Art. 227(1) FCCM-routing note). B31 delta callout flags PS1/26 Art. 227(2)(i) (new unfettered-seizure condition) and PS1/26 Art. 227(4) (new master-netting-agreement rule) as B31 additions. Plan-item misattribution corrected: D4.49 cited "Art. 227(4)" but the 4-business-day window actually sits at Art. 227(2)(d) in the consolidated UK CRR. Closes DOCS_IMPLEMENTATION_PLAN.md D4.49. Ref: CRR Art. 227(2)(d), Art. 227(1), Art. 222(4); PRA PS1/26 Art. 227(2)(d), Art. 227(2)(i), Art. 227(4). - OF-ADJ T2 component caps (Art. 62(c) / Art. 62(d) / Art. 92(2A) GCRA) documented (
docs/specifications/basel31/output-floor.md): the OF-ADJ formulaOF-ADJ = max(0, SA-RWA × OF% − IRB-RWA)was published without context for the three Tier-2 caps that interact with the floor reconciliation, leaving a gap between the formula in the spec and the upstream-caps-vs-engine-cap split thatengine/aggregator/_floor.py::compute_of_adjactually implements. New "T2 Component Caps — Art. 62(c) and Art. 62(d)" subsection (framed as a clarification, not a new mechanic) covers IRB T2 (Art. 62(d), 0.6% of IRB credit-risk RWA, applied upstream), SA T2 (Art. 62(c), 1.25% of SA credit-risk RWA, applied upstream), and the engine-applied GCRA cap (Art. 92(2A), 1.25% of S-TREA), with verbatim Art. 92(2A) quote, GCRA-vs-SA-T2 sign/base distinction, worked numeric illustration, and a CRR delta note (Art. 62 caps exist under both frameworks but the OF-ADJ linkage is B31-only). Plan-item misattribution corrected: D4.50 cited "Art. 92(3)(c)" but the cap locations are Art. 62(c) / Art. 62(d) of the Own Funds (CRR) Part — Art. 92(3) is the U-TREA composition list with no point (c) cap. Closes DOCS_IMPLEMENTATION_PLAN.md D4.50. Ref: PRA PS1/26 Art. 62(c), Art. 62(d), Art. 92(2A). - Art. 40 EL-shortfall DTA grossing-up rule explained in OF-ADJ context (
docs/specifications/basel31/output-floor.md): the previous gloss "plus any supervisory deductions under Art. 40" in the IRB_CET1 component row mischaracterised CRR Art. 40 as a separate prudential filter / supervisory deduction. New "Art. 40 — no deferred-tax grossing-up of the EL-shortfall deduction" subsection adds verbatim CRR Art. 40 text and a plain-English explanation that Art. 40 is a clarifier on Art. 36(1)(d) — it forbids reducing the EL-shortfall deduction by a rise in deferred-tax assets reliant on future profitability. The component-table row now points at the new subsection rather than restating the misattribution. Engine-inputs note covers both the engine-derivedELPortfolioSummary.cet1_deductionpath and the institution-suppliedOutputFloorConfig.art_40_deductionsscalar. Closes DOCS_IMPLEMENTATION_PLAN.md D4.51. Ref: CRR Art. 40, PRA PS1/26 Art. 92(2A). - Equity transitional 3-year window (Rules 4.4–4.10) distinguished from output-floor 4-year transitional (
docs/specifications/basel31/equity-approach.md): the spec previously implied the SA equity transitional ran four years through 31 Dec 2030; PRA PS1/26 Annex C Chapter 4 Rule 4.2 chapeau is unambiguous that both the SA equity transitional (Rules 4.1–4.3) and the IRB equity/CIU opt-out transitional (Rules 4.4–4.10) run only 3 years (1 Jan 2027 – 31 Dec 2029), with steady-state from 1 Jan 2030. Side-by-side comparative table now shows scope/dates/mechanism/opt-out for the two regimes, plus an info admonition warning against conflating equity transitional (3 years) with output-floor transitional (4 years, Art. 92(5)). Rules 4.4, 4.7, 4.9, 4.10 quoted verbatim from PS1/26 Appendix 1. Plan-item correction: D4.52 itself stated "SA equity transitional runs 4 years (2027-2030)" — the 4-year window is the output-floor transitional, not equity. Closes DOCS_IMPLEMENTATION_PLAN.md D4.52. Ref: PRA PS1/26 Annex C Chapter 4 Rules 4.1–4.11, Art. 92(5). - HVCRE Table B EL subgrade columns A/B/C/D surfaced (
docs/specifications/basel31/slotting-approach.md): the B31 HVCRE expected-loss row was previously rendered as a single "Strong = 0.4%" entry without exposing the four subgrade columns that PS1/26 Appendix 1 Art. 158(6) Table B uses for both HVCRE and non-HVCRE rows. The HVCRE EL table is now expanded to four explicit columns (A/B/C/D) parallel to the existing Table A risk-weight subgrade structure (which DOES split: 70%/95%/95%/120%); Art. 158(6) Table B is quoted verbatim. Plan-item correction: D4.53 plan wording "Strong A = 0.4%, Strong = 0.8%" conflated HVCRE Table B (flat 0.4% across all four columns) with the non-HVCRE EL row (where Good C = 0.4% and Good D = 0.8%). Closes DOCS_IMPLEMENTATION_PLAN.md D4.53. Ref: PRA PS1/26 Appendix 1 Art. 153(5) Table A, Art. 158(6) Table B. - CRR Art. 121(4) trade-finance preferential 50%/20% for unrated institutions documented (
docs/specifications/crr/sa-risk-weights.md): the institution section gains a dedicated Art. 121(4) subsection — verbatim Art. 121(4) (CRR p. 120), Art. 162(3) second subparagraph point (b) (p. 160), and Art. 4(1)(80) (p. 39) quotes; per-case RW table; cumulative eligibility checklist; B31 framework-delta callout flagging the SCRA restructuring and the absence of a flat-50% successor; implementation-status callout flagging the CRR calculator gap. Plan-item terminology correction: D4.55 wording said "50% (sovereign CQS 4-5) or 20% (sovereign CQS 1-3) under sovereign-derived approach" — Art. 121(4) is not CQS-keyed; the 50% is flat for all eligible trade-finance exposures (residual ≤ 1y), and 20% applies where residual ≤ 3 months. Closes DOCS_IMPLEMENTATION_PLAN.md D4.55. Ref: CRR Art. 121(4), Art. 162(3) second subpara point (b), Art. 4(1)(80). - PS1/26 Art. 132(8) "relevant CIU" PRA notification regime documented (
docs/specifications/basel31/equity-approach.md): a new section covers the third-country-fund-manager notification trigger that previously had no doc surface — verbatim Art. 132(8)(a)–(d) (PS1/26 App 1 pp. 64–65) and Glossary "relevant CIU" definition (p. 27), plain-English summary, distinction from other CIU notification regimes, and a CRR comparison (Art. 132 omitted from UK CRR by SI 2021/1078; the regime is B31-only). Three plan-item misattributions recorded: (a) the cited articles 132(3A) / 132(3B) do not exist — the actual provision is Art. 132(8); (b) no AML/CFT trigger exists anywhere in PS1/26 — the genuine trigger is the fund manager's third-country domicile, not establishment country, not AML/CFT assessment; (c) the threshold is 0.5% of credit-risk + dilution-risk RWA OR GBP 500m exposure value, not "≥2% of own funds". The same new spec section also fully covers D4.66's misattributed Art. 132(4A) / GBP 2bn RWA / GBP 500m references — D4.66 should be closed in the next plan refresh. Closes DOCS_IMPLEMENTATION_PLAN.md D4.58. Ref: PRA PS1/26 Art. 132(8), Glossary p. 27. - CRR Art. 118(f) UK-exit deletion noted (
docs/specifications/crr/sa-risk-weights.md): the Art. 118 0% list for international organisations was previously documented as the EU-onshored Art. 118 in full, with no flag that item (f) — the residual "two-or-more-Member-States international financial institution" catch-all — was omitted by SI 2018/1401 reg. 116 with effect from 31 December 2020. New warning admonition under the existing International Organisations subsection sets out the pre-deletion EU text, the SI reference, and the practical effect (Art. 118 closes to items (a)–(e) only — IMF, BIS, EU, ESM, EFSF, EIB; cross-Member-State financial institutions no longer qualify under UK CRR). Plan-item misattribution corrected: D4.56 framed Art. 118 as "exposures to recognised exchanges" — Art. 118 is the international-organisations 0% list; recognised exchanges sit in Art. 107 / Art. 197–198. Closes DOCS_IMPLEMENTATION_PLAN.md D4.56. Ref: UK CRR Art. 118 (consolidated, footnote F266); The Capital Requirements (Amendment) (EU Exit) Regulations 2018, SI 2018/1401 reg. 116. - PS1/26 Art. 122B / Art. 139(2B) SA Specialised Lending in S-TREA documented (
docs/specifications/basel31/output-floor.md): the SA SL framework introduced by PS1/26 Art. 122A–122B was previously absent from the output-floor spec — practitioners had no documented basis for how an IRB firm using SA for specialised lending under Art. 122A contributes to S-TREA. New section covers the Art. 122A sub-classification (Project Finance / Object Finance / Commodities Finance / IPRE / HVCRE) and Art. 122B routing (Art. 122B(1) rated → Table 5A short-term ECRA; Art. 122B(2)/(4) unrated ladder; Art. 122B(3) operational-phase definition; Art. 122B(5) high-quality criteria). The Art. 139(2B) ECAI rating-attribution rule is documented as a suppression of Art. 139(2)/(2A) inferred fallbacks when the rated SL pathway is invoked — not as a S-TREA exclusion. Plan-item factual correction: D4.59 wording — "IRB firms using SA for specialised lending do not include those exposures in the output floor SA-RWA calculation" — is factually wrong; SA SL exposures contribute to S-TREA in full (just routed through Art. 122B), and Art. 139(2B) is an ECAI rule, not a carve-out. Misattribution recorded inline via a warning admonition. Closes DOCS_IMPLEMENTATION_PLAN.md D4.59. Ref: PRA PS1/26 Art. 122A, Art. 122B(1)–(5), Art. 139(2)–(2B), Art. 92(2A). - PS1/26 Art. 143(6)–(8) Overseas Model Approach documented (
docs/specifications/basel31/model-permissions.md): the new PS1/26 Overseas Model Approach (OMA) — a permission for UK-parent groups to apply a foreign supervisor-approved IRB approach to retail and SME corporate exposures of equivalent-jurisdiction overseas subsidiaries, capped at 7.5% of group RWA and 7.5% of group exposure value pre-output-floor — was completely absent from the docs. New top-level section covers Art. 143(6) substantive permission with the (a)–(k) conditions and the aggregate cap, Art. 143(7) grandfathering as a deeming provision for pre-2027 CRR Art. 143 PRA permissions, and Art. 143(8) ongoing-compliance obligation, with verbatim PS1/26 App 1 quotes (pp. 79, 83–84) and a CRR-vs-B31 delta (CRR has no structured OMA). Plan-item paraphrase corrected in three respects: (a) Art. 143(7) grandfathers an existing PRA permission, not a standalone overseas-regulator approval; (b) the mechanism is a deeming provision, not a notification; (c) the substantive OMA in Art. 143(6) is far narrower than the plan suggested — restricted to retail / SME corporate, equivalent-jurisdiction, with the 7.5% group caps. Closes DOCS_IMPLEMENTATION_PLAN.md D4.60. Ref: PRA PS1/26 Art. 143(6)(a)–(k), Art. 143(7), Art. 143(8), Glossary p. 79. - PS1/26 Art. 191A(2)(e),(f) two-layer protection look-through documented (
docs/specifications/basel31/credit-risk-mitigation.md): the CRM spec previously had no description of the PS1/26 election allowing an institution to recognise funded collateral posted by an unfunded protection provider directly through the guarantee chain. New "Look-Through for Unfunded Protection Backed by Funded Protection (Art. 191A(2)(e), (f))" sub-section, slotted inside the existing CRM Method Taxonomy (Art. 191A) block, gives verbatim Art. 191A(2)(e) and (2)(f) (PS1/26 App 1 p. 168), a three-option election table (funded only / unfunded + funded jointly / Part-3-only fallback), the Art. 191A(2)(f) borrower-deeming flexibility, a CRR↔PS1/26 comparison flagging this as wholly new under PS1/26, cross-references to FCSM/FCCM/Foundation Collateral Method/PSM/RWSM and Art. 237–239, and an implementation-status admonition flagging the engine gap. Plan-item misattribution corrected: D4.61 cited "Art. 191A(4)" — the actual provision is Art. 191A(2)(e)/(f); Art. 191A(4) is an unrelated cross-reference scoping rule for Articles 192–239 absent an explicit cross-reference. Closes DOCS_IMPLEMENTATION_PLAN.md D4.61. Ref: PRA PS1/26 Art. 191A(2)(e)–(f), Part 4 of Appendix 1. - CRR Art. 132 paragraph references corrected in CRR equity spec (
docs/specifications/crr/equity-approach.md): the CRR equity spec previously labelled CIU look-through and mandate-based approaches with PRA PS1/26 article numbers (132A / 132B). Under the historical UK CRR — before SI 2021/1078 omitted Art. 132 effective 1 Jan 2022 — these were paragraphs within Art. 132 itself: para 4 = look-through, para 5 = mandate-based. Article numbers throughout the CRR-context tables, section headings (CIU Treatment, Look-Through Approach, Mandate-Based Approach, Fallback Approach), the FR-1.7b requirements row, the CRR-J15 acceptance scenario, and the CRR-J16 third-party multiplier note are now retitled with pre-omission paragraph citations (Art. 132(4) / Art. 132(5) / Art. 132(2)). New top-of-page warning callout summarises the regulatory history: SI 2021/1078 omission, PRA Rulebook (CRR Part) housing through 31 Dec 2026, and PRA PS1/26 reintroduction as Art. 132A / 132B / 132C from 1 Jan 2027. Cross-links tobasel31/equity-approach.mdfor the Art. 132A treatment. Closes DOCS_IMPLEMENTATION_PLAN.md D4.65. Ref: CRR Art. 132(2), (4), (5) (pre-omission); SI 2021/1078; PRA PS1/26 Art. 132A, 132B, 132C. - CRR Art. 150(1)(a)–(j) permanent partial use spec mirroring B31 (
docs/specifications/crr/model-permissions.md): previously no spec file documented the CRR Art. 150 PPU framework — practitioners had to reverse-engineer the conditions for SA-within-IRB fromIRBPermissions/permission_modeconfig code. The new spec is a CRR-side mirror ofbasel31/model-permissions.mdso the two pages diff cleanly. Contents: a sunset warning that CRR Art. 150 expires 31 Dec 2026 with cross-link to PS1/26 Art. 150(1A); verbatim Art. 150(1) opening and conditions (a)–(j) (crr.pdfpp. 145–146); plain-English summary table covering each condition, plus admonitions on the (a)/(b) "limited material counterparties" two-limb test, the qualitative immateriality test in (c) (vs B31's numeric thresholds), the SI 2018/1401 UK-Exit re-targeting of (d), and the standalone 10% own-funds cap in (h); verbatim Art. 150(2) text plus a tier table (10% threshold for ≥10 holdings, 5% for <10 holdings) with a worked example; a 10-row CRR↔B31 comparison table; and an Engine Inputs section showing how each Art. 150(1)(a)–(j) condition is (or is not) encoded. Plan-item correction: there is noapply_partial_ppuflag onCalculationConfig— PPU under the engine is implicit inIRBPermissions(a class withpermitted={SA}is effectively PPU for that class). Closes DOCS_IMPLEMENTATION_PLAN.md D4.64. Ref: CRR Art. 150(1)(a)–(j), Art. 150(2); PRA PS1/26 Art. 150(1A). - OF 08.01 col 0260 (post-adjustment RWEA) documented (
docs/specifications/output-reporting.md): the OF 08.01 column list jumped from col 0254 to col 0265 with no entry for the intermediate col 0260. Per PS1/26 Annex II §3.3.1 p. 112, col 0260 = "Risk-Weighted Exposure Amount After Adjustments" =0251 + 0252 + 0253 + 0254and is the post-adjustment RWEA feeding OF 02.00 row 0010. Now inserted in the correct PDF order between cols 0254 and 0265. Closes DOCS_IMPLEMENTATION_PLAN.md D4.69. Ref: PRA PS1/26 Annex II §3.3.1 p. 112. - OF 08.07 row 0270 / col 0180 PPU formulas documented (
docs/framework-comparison/reporting-differences.md): rows 0260/0270 were previously listed as "Added" without their formulas, leaving COREP implementers to reverse-engineer the Art. 150(1A) PPU materiality calculations from the Annex II PDF. Now expanded with verbatim PS1/26 Annex II formulas: col 0160 =col 0100 / CA2 row 0040(Art. 150(1A)(c)), col 0170 =sum(0110+0120) / (col_0060 - col_0070)(Art. 150(1) last subparagraph), and col 0180 / row 0270 =row_0260_col_0120 / sum(col_0060 for rows 0180-0250 where col_0150 > 0)(Art. 150(1A)(e)). Cross-link tobasel31/model-permissions.mdinstead of duplicating the Art. 150(1A) materiality regime. Plan-item factual correction: D4.71 stated row 0270 / col 0180 usessum(0110+0120)/(col_0060-col_0070)— that formula actually defines col 0170; col 0180 / row 0270 uses the Art. 150(1A)(e) formula (row 0260 col 0120 / Σ col 0060 for material rows). Closes DOCS_IMPLEMENTATION_PLAN.md D4.71. Ref: PRA PS1/26 Annex II §3.3 OF 08.07 pp. 134–136. - UKB CR7-A PDF col labelling typo flagged (
docs/framework-comparison/disclosure-differences.md): PRA PS1/26 Annex XXII p. 14 reuses col (n) for unfunded credit protection on slotting exposures (intended label is col (p)). Existing UKB CR7-A column-changes table already showed the corrected o/p sequence; new warning admonition documents the PDF typo so implementors do not follow the PDF literally. Closes DOCS_IMPLEMENTATION_PLAN.md D4.73. Ref: PRA PS1/26 Annex XXII p. 14. - CRR double-default eligibility, RW floor, and A-IRB precondition corrected in user-guide CRM (
docs/user-guide/methodology/crm.md): the user-guide double-default subsection previously cited "Art. 153(3) paragraph 2" for the RW floor (Art. 153(3) para 2 is blanked under PS1/26 — the CRR floor lives elsewhere), gave an ambiguous "CQS 2 or better (CQS 3 maintained threshold)" guarantor eligibility wording inconsistent with CRR Art. 202, and omitted the A-IRB own-LGD precondition entirely. The RW floor citation is now CRR Art. 161(3) (the comparable-direct-exposure-to-guarantor floor); the CQS threshold is rebuilt from verbatim Art. 202(b)/(c)/(d) (ECAI ≥ CQS 3 at provision; historical PD ≤ CQS 2; current PD ≤ CQS 3); the A-IRB own-LGD chain (Art. 153(3) → Art. 161(4) → Art. 161(3)) is set out explicitly with the F-IRB fall-back to Art. 235/236 substitution. Underlying-exposure scope re-stated to match Art. 153(3) (corporate, institution, central government/CB, retail SME via Art. 154(2)) — replacing the prior incorrect "RGLA/PSE" claim. Closes DOCS_IMPLEMENTATION_PLAN.md D4.76. Ref: CRR Art. 153(3), Art. 154(2), Art. 161(3)–(4), Art. 202; PRA PS1/26 (Art. 153(3) para 2 blanked). - Art. 155(2) short-position netting and Art. 155(4) IMA floor added to user-guide equity (
docs/user-guide/methodology/equity.md): the user-guide page documented the IRB Simple RW table (190%/290%/370%) but two material Art. 155 sub-rules — already present in the CRR equity spec — were missing from the practitioner reference. Added subsections on (a) Art. 155(2) short-position netting (short cash positions and non-trading-book derivatives may offset long positions in the same individual stock only if the hedge is explicit and covers ≥ 1 year; otherwise treated as long with the RW applied to the absolute value) and (b) Art. 155(4) IMA approach (12.5 × VaR-derived potential loss; portfolio RWEA must not be lower thanPD/LGD RWEA + EL × 12.5using Art. 165(1)/(2) PD floors and LGDs). Cross-link tocrr/equity-approach.mdfor the canonical Art. 155 RW table; B31 framework-delta callout flags PS1/26 Art. 147A removal of IRB Equity Approach with the Rules 4.4–4.10 transitional path. Art. 155(3) per-exposure cap deliberately not pre-empted (owned by D4.79). Closes DOCS_IMPLEMENTATION_PLAN.md D4.77. Ref: CRR Art. 155(2), Art. 155(4), Art. 165(1)–(2); PRA PS1/26 Art. 147A, Annex C Chapter 4 Rules 4.4–4.10. - Art. 155(3) PD/LGD per-exposure cap added to user-guide equity (
docs/user-guide/methodology/equity.md): CRR Art. 155(3) caps the PD/LGD-approach capital for any individual equity exposure at a 100% loss assumption (EL × 12.5 + RWEA ≤ EAD × 12.5), but the user-guide page only mentioned the cap inline as a contrast within the Art. 155(4) IMA-floor warning callout — easily overlooked by practitioners scanning for PD/LGD mechanics. New dedicated "PD/LGD Approach Per-Exposure Cap (Art. 155(3))" subsection sits between Short-Position Netting and the IMA section, with verbatim cap formula, a non-binding worked example (EAD=£100, PD=0.40%, LGD=90% → LHS ≈ £374.50 ≪ RHS = £1,250) and a binding example showing the cap reducing PD/LGD RWEA from £1,500 to £350 for a near-default exposure with the Art. 155(3) 1.5× scaling factor applied. Cross-references the canonical PD/LGD parameter table incrr/equity-approach.mdrather than duplicating it. PRA PS1/26 Art. 147A removal callout flags that the cap has no Basel 3.1 successor. Implementation-status note records that the calculator currently implements only Art. 155(2) Simple Risk Weight Approach (PD/LGD approach isIMPLEMENTATION_PLAN.mdP1.153 follow-up), so the per-exposure cap does not bite in any current calculation path. Closes DOCS_IMPLEMENTATION_PLAN.md D4.79. Ref: CRR Art. 155(3), Art. 153(1), Art. 165(1)–(3); PRA PS1/26 Art. 147A. - UKB OV1 pre-floor capital ratio rows documented (
docs/features/pillar3-disclosures.md): Pillar 3 OV1 row table previously listed only rows 1–5, 11–14, 24, 26, 27, 29 — missing the seven UKB-specific pre-floor rows (4a Total RWEAs (pre-floor); 5a/5b CET1; 6a/6b Tier 1; 7a/7b Total capital pre-floor capital ratios). These rows are mandatory under PRA PS1/26 Annex XX for output-floor-active institutions so market participants can see the pre-floor capital position separately from the post-floor figures driven by Art. 92(5). Cross-references toframework-comparison/disclosure-differences.md(lines 31, 63–64) carry the canonical CRR-vs-Basel 3.1 row delta. The correspondingB31_OV1_ROWSgap insrc/rwa_calc/reporting/pillar3/templates.pyis routed to IMPLEMENTATION_PLAN.md as a separate code-side P-coded item. Closes DOCS_IMPLEMENTATION_PLAN.md D4.81. Ref: PRA PS1/26 Annex XX (Disclosure (CRR) Part Art. 438(d)), Art. 92(5). - CRR Art. 137 ECA score open-gap surfaced in CRR SA spec (
docs/specifications/crr/sa-risk-weights.md): the previous "Implementation Status" note at the end of the Art. 137 ECA section understated the gap as a "future enhancement" with no mention of which inputs the engine actually accepts today. Replaced with a new### Art. 136 vs Art. 137 — two distinct mappingssubsection (clarifying that Art. 136 routes ECAI grades through the CQS pipeline and Art. 137 routes OECD MEIP integers 0–7 directly through Table 9 to risk weights) followed by an explicit "Open Gap" warning admonition. The admonition records that the engine accepts only a rawcredit_quality_stepinteger (no ECAI grade strings, no MEIP integers), enumerates what a complete implementation would need (input-schema field for either an ECAI grade or an Art. 137 MEIP integer 0–7, static lookup table indata/tables/, Art. 114/121 sovereign-derived wiring, Art. 138 multi-assessment selection logic), and surfaces the engine work forIMPLEMENTATION_PLAN.mdtracking. Verbatim Art. 137 Table 9 (0%/0%/20%/50%/100%/100%/100%/150%) PDF-verified againstdocs/assets/crr.pdfp. 135. Closes DOCS_IMPLEMENTATION_PLAN.md D4.74. Ref: CRR Art. 136(1)–(2), Art. 137(1)–(2) Table 9. - UKB CR8 signed-flow convention documented (
docs/features/pillar3-disclosures.md): the Pillar 3 disclosures page CR8 section previously showed only the row structure with no sign convention, leaving template consumers to infer the direction of flow rows 2–8 from the spec page or the PRA Annex XXII text. New!!! warningadmonition under the row-structure table records that flow rows 2–8 use signed values (increases positive, decreases negative; example: a £15m RWEA decrease emits as-15) and cross-references the canonical sign-convention list atdocs/specifications/output-reporting.mdlines 349 / 356–366 instead of duplicating spec text. The corresponding_generate_cr8gap insrc/rwa_calc/reporting/pillar3/generator.py(rows 2–8 currently emitted asNonebecause multi-period comparison data is not wired through the pipeline) is surfaced for IMPLEMENTATION_PLAN.md routing — the implementation must honour the signed convention when prior-period inputs are added. Closes DOCS_IMPLEMENTATION_PLAN.md D4.82. Ref: PRA PS1/26 Annex XXII §11 (UKB CR8 instructions). - UKB CMS1 / CMS2 col d ↔ OF-ADJ / GCRA reconciliation surfaced (
docs/features/pillar3-disclosures.md): the Pillar 3 page documented CMS1 col d as "RWA calculated using full standardised approach" without flagging that this is the pre-OF-ADJ S-TREA input matching OF 02.01 col 0040 — i.e. the S-TREA leg ofTREA = max{U-TREA; x · S-TREA + OF-ADJ}before the floor multiplier and OF-ADJ are applied. New "Col d — pre-OF-ADJ S-TREA input" subsection on UKB CMS1 carries verbatim Art. 92(2A) (PS1/26 App 1 p. 13), an info admonition explaining how CMS1 col d gates GCRA T2 capacity through the 1.25%-of-S-TREA cap (Art. 62(c)), and cross-links tospecifications/basel31/output-floor.md(formula derivation + GCRA/SCRA boundary) andspecifications/output-reporting.md(COREP mapping). The UKB CMS2 section gains a parallel "Col d — pre-OF-ADJ S-TREA at asset-class granularity" subsection confirming CMS2 col d carries the same pre-OF-ADJ semantics as CMS1 col d (asset-class breakdown of the same population) and pointing back to the CMS1 admonition rather than duplicating the formula. Closes DOCS_IMPLEMENTATION_PLAN.md D4.83. Ref: PRA PS1/26 App 1 Art. 92(2A), Art. 62(c). - QCCP guarantor RW override (Art. 306) surfaced in user-guide CRM (
docs/user-guide/methodology/crm.md):engine/irb/guarantee.py::_compute_guarantor_rw_saoverrides the substituted guarantor risk weight to 2% (proprietary) or 4% (client-cleared) when the guarantor is a qualifying central counterparty (gated byguarantor_entity_type == "ccp"andguarantor_is_ccp_client_cleared), but the user-guide CRM page never referenced Art. 306 — practitioners had to read the engine source to discover the override. New "Qualifying CCP (QCCP) Guarantor Override (CRR Art. 306)" subsection inside the existing Guarantees section sets out the trigger flags, the 2% / 4% RW table with CRE54.14 / CRE54.15 cross-references, the ordering versus the institution CQS lookup, a scope warning (trade-exposure RW only — default-fund contributions go through Art. 308 / CRE54.16 separately), a worked example, and cross-links to the Institution exposure-class page (CCP anchor) andspecifications/output-reporting.md(COREP rows 0150 / 0160). Closes DOCS_IMPLEMENTATION_PLAN.md D4.85. Ref: CRR Art. 306(1)(a)–(b), Art. 308; PRA PS1/26 Art. 306; BCBS CRE54.14, CRE54.15, CRE54.16. - Art. 232 life-insurance spec ↔ output-column cross-reference + worked example (
docs/specifications/basel31/credit-risk-mitigation.md): the B31 CRM spec described the Art. 232 7-tier insurer-RW → secured-portion-RW mapping (20% / 35% / 70% / 150%) and the F-IRB LGDS=40% rule but did not link these mechanics to the engine output columnslife_ins_collateral_value/life_ins_secured_rwproduced byengine/crm/life_insurance.py::compute_life_insurance_columnsand consumed during SA blending bylf.sa.apply_life_insurance_rw_mapping(). The previous stale!!! warning "Output-column naming documented separately"admonition (referencing the now-closed D3.53 / D2.48) is replaced with (a) a new#### Spec ↔ Output-Column Cross-Referencesubsection — a 5-row table mapping each Art. 232 mechanic to the engine column, default-value behaviour, andengine/crm/life_insurance.py/engine/sa/namespace.pyline numbers — and (b) a new#### Worked Examplesubsection — a fully traced 6-step calculation using a 30% insurer SA RW (SCRA Grade A enhanced, Art. 121(5)) → 35% secured-portion RW (Art. 232(3)(b)) → blended 0.61 RW = GBP 610,000 RWA versus GBP 1,000,000 unmitigated. Closes DOCS_IMPLEMENTATION_PLAN.md D4.86. Ref: PRA PS1/26 App 1 Art. 232(A1), (2)(a), (2)(b), (3)(a)–(d); Art. 121(5); Art. 200(b), Art. 212(2); Art. 233(3)–(4). - CRR Art. 129(5) covered bond unrated-derivation framework boundary clarified (
docs/specifications/crr/sa-risk-weights.md): the CRR covered bond unrated-derivation section previously documented the four sub-paragraphs (a)–(d) without flagging that the sharedCOVERED_BOND_UNRATED_DERIVATIONdict incrr_risk_weights.pycarries 7 entries (3 of which — 30%, 40%, 75% institution RWs — derive from PS1/26 SCRA Grade A/B and CQS 2 ECRA paths that do not exist in CRR). Practitioners reading the dict comment "CRR Art. 129(5), PRA PS1/26 Art. 129" risked treating the larger entry set as authoritative under both frameworks. New verbatim CRR Art. 129(5)(a)–(d) quote (crr.pdfp. 129) plus warning admonition explaining only four CRR institution RWs (20/50/100/150 from Art. 120 Table 3 and Art. 121 Table 5) drive Art. 129(5), producing 10/20/50/100 covered bond RWs; the 30%/40%/75% institution inputs driving PS1/26 sub-paragraphs (aa)/(ab)/(ba) (ps126app1.pdfpp. 61–62) cannot arise under CRR. Implementation-note admonition records that the dict comment reflects shared storage, not framework equivalence. Cross-links to in-page B31 covered bond changes section andbasel31/sa-risk-weights.mdunrated-covered-bonds section. Code-side structural fix continues under DOCS_IMPLEMENTATION_PLAN.md D3.29. Closes DOCS_IMPLEMENTATION_PLAN.md D4.62. Ref: CRR Art. 129(5); PRA PS1/26 Art. 129(5). - UKB CR9 row breakdown rewritten to PS1/26 Annex XXII column-a verbatim sub-classes (
docs/features/pillar3-disclosures.md): the Pillar 3 disclosures page CR9 section previously listed compact row labels (institutions / corporates with SL / "other general corporates SME/non-SME") that did not match the verbatim PRA PS1/26 Annex XXII column-asub-classes for either approach. F-IRB CR9 was missing the "Financial corporates and large corporates" row (Art. 147(2)(c)(ii) / Art. 147A driver) that already appears in CR6, leaving the two templates inconsistent for the same population. Rewritten to use the full numbered hierarchy: A-IRB rows 1.1–1.3 (corporates) and 2.1–2.7 (RRE-SME, RRE-non-SME, CRE-SME, CRE-non-SME, QRRE, Other-SME, Other-non-SME); F-IRB rows 1, 2.1 SL, 2.2 financial corporates and large corporates, 2.3–2.4 other general corporates SME/non-SME, 3 total, perps1-26-annex-xxii-credit-risk-irb-disclosure-instructions.pdfpp. 19–20. Cross-references the CR6 H2 anchor so the F-IRB sub-class 2.2 is recognisable as the same row in both templates; Reference Documents block expanded with verified PDF page numbers (Annex XXII p. 18 paras 12–15; pp. 19–20 columna; pp. 20–22 columnsb–h;ps126app1.pdfArt. 147(2)(b)–(d) and Art. 147A). Plan-item observation: D4.84's "missing financial corporates and large corporates row" framing was stale relative to the current file state — that row was already present from commit3d3b346; the residual docs gap was the absence of a CR6 cross-reference and the use of compact / non-verbatim row labels. The correspondingCR9_FIRB_CLASSESandCR9_AIRB_CLASSESgaps insrc/rwa_calc/reporting/pillar3/templates.py(missing F-IRB sub-classes 2.2 and 2.4, missing A-IRB sub-class 1.3, and the seven retail sub-classes 2.1–2.7) are routed to IMPLEMENTATION_PLAN.md as a separate code-side P-coded item. Closes DOCS_IMPLEMENTATION_PLAN.md D4.84. Ref: PRA PS1/26 Annex XXII paras 12–15; columnarow definitions on pp. 19–20.
[0.2.5] - 2026-05-02¶
Fixed¶
- Collateral CRM now nets against CCF=100% E per CRR Art. 223(4) / PS1/26 Art. 223(4) (
engine/crm/processor.py::_initialize_ead,engine/crm/collateral.py::_apply_collateral_unified,engine/ccf.py::_compute_ead): the CRM stage previously netted collateral againstead_gross = on_bal + nominal × CCF(post-CCF) for off-balance-sheet exposures, both in the FIRB LGD* formula and in the SAead_after_collateralreduction. Both CRR Art. 223(4) and PRA PS1/26 Art. 223(4) explicitly require the opposite: when computing the exposure valueEused for CRM (financial collateral via FCCM, other eligible collateral via the Foundation Collateral Method, and the C*/C** threshold tests in Art. 230), off-balance-sheet items shall be valued at 100% of nominal, overriding the regulatory CCF. The actual CCF re-couples afterwards: under SA per Art. 228(1) the CCF is applied toE*, while under FIRB the actual CCF stays in EAD but is absent from the LGD* ratio. Phil's worked example (100m off-BS FIRB, 75% CCF, 50m cash, senior unsecured): pre-fix code produced LGD* = 15%, regulation requires LGD* = 22.5% — code under-stated FIRB LGD by 7.5pp on this exposure. Under SA the same shape under-stated EAD: 100m off-BS, 50% CCF, 30m cash gave EAD = 20m; regulation requires(100−30) × 0.5 = 35m. Fix introduces two new columns on the exposures frame computed in_initialize_ead:ead_for_crm = on_bs_for_ead + nominal_after_provision(CCF=100% basis, used by all CRM-ratio sites) andeffective_ccf = ead_pre_crm / ead_for_crm(used to recouple the actual CCF in SA's post-collateral EAD).ead_gross(post-CCF) is kept unchanged in the schema — multiple downstream sites legitimately need the actual EAD that flows through to RWA. Migrated sites: collateral pro-rata weights for facility / counterparty pools (_build_exposure_lookupsinprocessor.pyand_apply_collateral_unifiedincollateral.py),_generate_netting_collateralallocation, Art. 230 RE 30% threshold cap, the Art. 231 sequential-fill waterfall denominator, the FIRB LGD* formula numerator and denominator,collateral_coverage_pct, and the SAead_after_collateralformula (rewritten from(ead_gross − collateral_adjusted_value)+to(ead_for_crm − collateral_adjusted_value)+ × effective_ccfper Art. 228(1)). Pure on-BS rows are unaffected (ead_for_crm == ead_grossby construction). FIRB / Slottingead_after_collateralcontinues to equalead_gross(collateral modifies LGD, not EAD, under those approaches). AIRB is unaffected (uses own LGD estimate). The CCF stage inengine/ccf.pynow persists the on-BS portion of EAD as a columnon_bs_for_ead(previously a local variable), enabling_initialize_eadto composeead_for_crmwithout recomputing the drawn / interest / provision adjustments. Defensive fallbacks added toapply_collateral,_apply_collateral_unified,_build_exposure_lookups, and_generate_netting_collateralso direct unit-test callers that hand-build exposures frames withead_grossonly continue to work (defaultead_for_crm = ead_gross,effective_ccf = 1.0— semantically correct for pure on-BS rows). New unit tests intests/unit/crm/test_ead_for_crm.py(8 tests): pure on-BS, pure off-BS independent of CCF (SA + FIRB), mixed on-BS+off-BS row blendedeffective_ccf, provision-on-nominal reducesead_for_crm, zero-nominal divide-by-zero guard, and two end-to-end pins through the full CRM processor — Phil's worked FIRB cash example assertinglgd_post_crm == 22.5%, and the SA off-BS analogue assertingead_after_collateral == 35m. Out of scope for this fix (tracked as follow-ups): guarantees Art. 235 / 236 (same regulatory shape —Ewith CCF=100% override — but a separate code surface inengine/crm/guarantees.py); life insurance under Art. 232 (routes via Art. 235 / 236, not FCCM/FCM, so falls under the guarantees follow-up); AIRB CRM under Art. 191A LGD Adjustment Method (uses own-estimate LGDs, not the FCCM/FCM pipeline). Full suite: 4717 unit + 336 contracts/integration + 497 acceptance pass — no existing acceptance fixture combined an off-BS exposure with collateral so no expected-outputs JSON shifted; the new behaviour is only triggered when both conditions are met. Spec updated indocs/specifications/crr/credit-risk-mitigation.md(new "Exposure value for CRM purposes (Art. 223(4))" section with worked cash and SA off-BS examples) anddocs/architecture/pipeline.md(processing-order section now describes the two-EAD-bases pattern). Ref: CRR Art. 111(3), Art. 223(3)–(5), Art. 228(1)–(2), Art. 230, Art. 231 (extracted fromdocs/assets/crr.pdfp.110, 219, 226–228); PRA PS1/26 Art. 166A–166C, Art. 223(4), Art. 228(1), Art. 230 (extracted fromdocs/assets/ps126app1.pdfp.117–120, 200–202, 208–210); BCBS CRE22.55. (a6e15b6.)
Changed¶
- Version bump for PyPI release.
[0.2.4] - 2026-04-30¶
Added¶
- Blog section + main-navigation link: new
docs/blog/series live on the Zensical site, with aBloglink added to the primary site navigation. First two posts published — including "Post 2 — The Pipeline" walking through the immutable bundle pipeline architecture. (874a510add nav link; PRs #289 / #290, commitscceaee4/7fe91fb.)
Changed¶
- MOF undrawn now emits per-sub waterfall rows by descending CCF (
engine/hierarchy.py::_calculate_facility_undrawn, new_expand_mof_facility_undrawn): replaces the prior worst-case single-CCF emission, where the MOF parent's full undrawn headroom flowed at the highest descendant CCF. Each MOF parent now emits onefacility_undrawnrow per committed descendant sub-facility with positive headroom, allocated by waterfall: subs are sorted by descending SA CCF (deterministic tie-break:risk_typethenfacility_reference) and filled in order, capped per-sub atmax(0, sub_limit - sub_drawn)and globally atparent_headroom. When sub-limits sum below the parent's limit, a residual row is emitted at the parent's ownrisk_typeandcounterparty_reference. Each split row carries the sub'srisk_typeandcounterparty_referencenatively, so the prior_derive_facility_share_counterpartyriskiest-CP override is now skipped on MOF parents (it still applies to non-MOF facilities). Per-sub drawn netting (loans + contingents directly mapped to a sub net only that sub's headroom — not the parent's) makes the waterfall reflect actual sub-level utilisation rather than rolling everything up to root before allocating. Uncommitted (committed=False) sub-facilities are skipped entirely from the waterfall — they consume no parent headroom, mirroring the existing parent-level rule that an unconditionally cancellable line carries no commitment EAD. Worked example for the user request that motivated the fix: parent £100m, sub_01 £60m @ MR (50% CCF), sub_02 £60m @ MLR (20% CCF). Old behaviour: 1 row £100m @ 50% → £50m EAD. New behaviour: 2 rows £60m @ 50% + £40m @ 20% (capped) → £38m EAD. Output schema:exposure_reference = "{parent}_UNDRAWN_{sub}"for waterfall rows,"{parent}_UNDRAWN_RESIDUAL"for the residual;source_facility_reference = parenton every row so facility-level collateral allocation and downstream rollups still group by the MOF parent;mof_risk_type_sourcerecords the sub each row came from (null on the residual). The retired private method_derive_mof_risk_typeis replaced by_expand_mof_facility_undrawn. Tests: 8 new unit tests inTestMOFAndFacilityShare(waterfall caps at parent limit, B31 CCF table, per-sub drawn netting, fully-drawn sub drops out, sub-limits-under-parent residual, three-subs mixed CCF, per-sub counterparty, all-undrawn per-sub counterparties, uncommitted sub skipped); 5 existing MOF tests updated to assert per-sub split rows; 4 multi-level facility undrawn tests updated to assert sum-across-rows equals parent headroom. Full unit suite: 4,692 passed; acceptance: 497 passed (1 skipped); contracts + integration: 317 passed. No acceptance goldens shifted because no existing fixture combined a MOF with sub-facilities of differing risk_types. Spec updated indocs/specifications/common/hierarchy-classification.md(new "Multi-Option Facility (MOF) Waterfall Allocation" subsection with two worked examples and edge-case enumeration). Ref: CRR Art. 111 (SA CCFs), Art. 166 (off-balance EAD); PRA PS1/26 Art. 111 Table A1, Art. 166C. (PRs #292 / #293.) - Version bump for PyPI release.
Fixed¶
- CRR F-IRB CCF over-statement for issued OBS items — implement Art. 166(10) fallback (
engine/ccf.py::_firb_ccf_for_col): the CRR F-IRB CCF helper previously blanket-applied 75% to everyMR/MLR/OCrow except the Art. 166(8)(b) short-term trade-LC carve-out, treating Art. 166(8)(d) as the catch-all. CRR Article 166 in fact has two F-IRB CCF clauses: Art. 166(8) prescribes bespoke CCFs for the named commitment types (UCC credit lines, short-term trade LCs, revolving purchased-receivables UCC, "other credit lines / NIFs / RUFs"), and Art. 166(10) is a self-contained residual fallback for off-balance sheet items not in scope of paragraphs 1–8 (100% FR / 50% MR / 20% MLR / 0% LR by Annex I category). The engine now distinguishes the two via a new boolean schema flagis_obs_commitment:True(Art. 166(8)(d) commitment-style — credit lines, NIFs, RUFs) routes to 75%;False(Art. 166(10) issued OBS item — performance bonds, warranties, tender bonds, non-credit-substitute documentary credits / standby LCs, shipping guarantees, customs/tax bonds, self-liquidating documentary credits) routes to the Annex I fallback (50% MR / 20% MLR). The Art. 166(8)(b)is_short_term_trade_lccarve-out continues to win over both buckets (it is a more specific Art. 166(8)(b) rule). Schema additions (data/schemas.py):is_obs_commitmentdefaults toTrueonFACILITY_SCHEMA(a facility row is, by construction, a commitment / credit line) andFalseonCONTINGENTS_SCHEMA(a contingent is, by construction, an issued OBS item); callers may override per row (e.g., a contingent that genuinely represents a NIF/RUF can be taggedTrue). The hierarchy stage (engine/hierarchy.py::_unify_exposuresand_calculate_facility_undrawn) projects the column with the per-source-table default. The CCF calculator's_ensure_columnsdefaultsis_obs_commitment=Trueas a final fallback for unit-test callers that bypass hierarchy, preserving all existing direct-API behaviour. Items affected (over-stated CCF before the fix): MR issued items — performance bonds, tender bonds, advance-payment guarantees, warranties, non-self-liquidating documentary credits, non-credit-substitute irrevocable standby LCs (75% → 50%, Art. 166(10)(b)); MLR issued items — self-liquidating documentary credits, shipping guarantees, customs and tax bonds (75% → 20%, Art. 166(10)(c)). Items unchanged: FR (100% under both Art. 166(8) general and Art. 166(10)(a)); LR UCC (0% under both Art. 166(8)(a) and Art. 166(10)(d)); OC commitments (75% via Art. 166(8)(d)); MLR withis_short_term_trade_lc=True(20% via Art. 166(8)(b)). Fix is CRR-only — Basel 3.1 Art. 166C already aligns F-IRB CCFs to SA Table A1 (50% MR, 20% MLR) so the over-statement only existed in theis_basel_3_1=Falsebranch. Test coverage: 7 new unit tests intests/unit/test_ccf.py(TestFIRBArt16610Fallback) covering MR-issued@50%, MR-commitment@75%, MLR-issued@20%, MLR-commitment@75%, MLR-issued+trade-LC@20% (carve-out priority), OC-issued@50%, and the missing-flag default; 3 new end-to-end integration tests intests/integration/test_classifier_to_crm.py(test_crr_d_ccf7_firb_mr_contingent_falls_to_50_via_art_166_10,test_crr_d_ccf8_firb_mlr_contingent_falls_to_20_via_art_166_10,test_firb_mr_facility_undrawn_keeps_75_via_art_166_8d) that drive data throughHierarchyResolver→ExposureClassifier→CRMProcessorand confirm the per-source-table default routing. Stress / benchmark fixture data updated (tests/acceptance/stress/conftest.py,tests/benchmarks/data_generators.py) and the test fixture builders (tests/fixtures/exposures/facilities.py,tests/fixtures/exposures/contingents.py) gain an optionalis_obs_commitmentfield. Module docstring andCCFCalculatorclass docstring updated to cite Art. 166(8)(a)/(b)/(d) and Art. 166(10), and to correct the prior Art. 166(9) misattribution for short-term trade LCs (Art. 166(9) is in fact the lower-of-two-CCFs rule for overlapping commitments, already handled viaunderlying_risk_type). Spec updated indocs/specifications/crr/credit-conversion-factors.md(new "F-IRB CCFs by source" tables splitting Art. 166(8)(d) credit lines from Art. 166(10) issued items; new CRR-D.CCF7 / CRR-D.CCF8 scenarios). Full suite: 5,531 passed (1 skipped, 11 deselected) — no acceptance goldens shifted because no existing CRR FIRB acceptance fixture combined an FIRB-classified counterparty with an MR or MLR contingent that previously expected 75%. Ref: CRR Art. 166(8)(a)–(d), Art. 166(10) (extracted verbatim fromdocs/assets/crr.pdf). (PR #291.)
[0.2.3] - 2026-04-28¶
Added¶
- Oracle test suite scaffold (PR #286,
301f77f): newtests/oracle/directory containing a small set of hash-locked, hand-derived expected values for SA / IRB scenarios that act as a third-party-friendly oracle independent of the existing acceptance goldens. Each fixture row carries a SHA-256 hash so any drift in inputs or expected outputs is detected as a hash mismatch rather than a silent recomputation. Initial scaffolding only; no production-engine changes.
Changed¶
- Facility undrawn generation now respects the
committedflag (engine/hierarchy.py::_calculate_facility_undrawn): the dormantcommittedBoolean onFACILITY_SCHEMAis now consulted by the hierarchy resolver. Facilities withcommitted=Falseno longer generate a syntheticfacility_undrawnexposure row — an unconditionally cancellable line carries no irrevocable lending commitment, so the bank holds no commitment EAD / RWA against the unused headroom (consistent with the regulatory intuition under CRR Art. 166 and PRA PS1/26 Art. 166C). Loans and contingents already mapped to such facilities are completely unaffected: they remain independent exposure rows with normal counterparty / parent rollup, collateral allocation, and CCF treatment, because they are already on-balance-sheet (loans) or carry their own off-balance EAD (contingents). The schema default forcommittedwas flipped fromFalsetoTrue(data/schemas.py:69) so legacy callers and fixtures that omit the field continue to generate undrawn rows as before — uncommitted is now the explicit, opted-in case. Nullcommittedvalues are also defensively treated as committed. New unit tests intests/unit/test_hierarchy.py:test_uncommitted_facility_suppresses_undrawn_row(no row generated forcommitted=False),test_committed_null_treated_as_committed(null defaults to committed),test_uncommitted_facility_loans_still_flowandtest_uncommitted_facility_contingents_still_flow(mapped loans/contingents flow through_unify_exposuresunchanged, nofacility_undrawnsynthetic row in the unified output). The mislabeledtest_facility_uncommitted_lr_risk_type(which actually usedcommitted=True) was renamed totest_facility_lr_risk_type. No CCF or downstream calculator changes — thecommittedgate simply stops feeding suppressed rows into the existing CCF pipeline. Full suite: 5,521 passed (1 skipped, 11 deselected) — no acceptance goldens shifted because the only fixture that previously heldcommitted=False(FAC_CORP_UNCOMMIT_001intests/fixtures/exposures/facilities.py) was already declared with the comment "0% CCF for unconditionally cancellable" and not asserted against in any expected-output JSON. Docs updated indocs/data-model/input-schemas.md(facilitycommittedrow) anddocs/architecture/components.md(HierarchyResolver method table). Ref: CRR Art. 166 (off-balance-sheet item EAD); PRA PS1/26 Art. 166C (Basel 3.1 CCF treatment for unconditionally cancellable commitments). (PR #288.) - Version bump for PyPI release.
Docs¶
- README accuracy refresh (PR #287,
fce582e): refreshed the project README to match the current state of the codebase — updated test counts, the supported exposure classes table, and the Basel 3.1 implementation status section.
[0.2.2] - 2026-04-27¶
Added¶
- Multiple Option Facility (MOF) and Facility Share support in the undrawn allocation pipeline (
engine/hierarchy.py::_calculate_facility_undrawn): two product patterns that the facility/undrawn pipeline previously did not honour are now applied as overrides on the parent facility's undrawn exposure row, without requiring schema changes. MOFs: any facility with at least onechild_type='facility'row infacility_mappingsis now treated as a Multiple Option Facility — the parent's undrawnrisk_typeis overridden to the descendant sub-facilityrisk_typewhose SA CCF (viaengine/ccf.py::sa_ccf_expression, frame-aware for CRR / PRA PS1/26 Table A1) is highest, so the parent's undrawn EAD reflects the worst-case off-balance commitment among its components rather than the parent's own (often LR / 0%)risk_type. Tie-break on alphabetical lowercase risk_type then alphabetical descendantfacility_referencefor full reproducibility. The new private method_derive_mof_risk_type()walks the existing_build_facility_root_lookup()output to collect descendants at any depth. Facility Shares: when the descendant loans / contingents under a facility reference more than one distinctcounterparty_reference, the undrawn is now allocated to the riskiest member by SA-equivalent risk weight rather than to the facility's owncounterparty_reference. The new private method_derive_facility_share_counterparty()collects the union of distinct counterparties from the descendant loan/contingent set (using the existing_resolve_to_root_facility()helper) and joins each candidate to the resolved counterparty lookup to readentity_typeandcqs. A new module-level helper_preview_sa_rw_expr()mapsentity_typeto the matching SA risk weight table (CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS, frame-awareINSTITUTION_RISK_WEIGHTS_*,CORPORATE_RISK_WEIGHTS,RETAIL_RISK_WEIGHT,MDB_RISK_WEIGHTS_TABLE_2B,HIGH_RISK_RW) and returns the RW for the candidate's CQS — deliberately SA-only so the preview avoids the circular dependency with the classifier's IRB approach gating; the chosen counterparty still flows through the full classifier and SA/IRB pipeline downstream so the preview is non-binding. Per facility, max RW wins; tie-break on higher CQS then alphabeticalcounterparty_reference. Both overrides are skipped no-ops when their inputs are trivial: a facility with nochild_type='facility'rows is not a MOF (parent risk_type unchanged); a facility with ≤1 distinct member counterparty is not a share (counterparty unchanged). Two new audit columns flow on thefacility_undrawnexposure rows for traceability —original_counterparty_reference(the facility's owncounterparty_referencebefore the share override) andmof_risk_type_source(the descendant facility reference whoserisk_typewon the max-CCF tie). The_calculate_facility_undrawn()signature gains optionalcounterparty_lookupandconfigparameters;_unify_exposures()plumbs the sameconfigthrough fromresolve()so the framework switch reaches both helpers.FACILITY_MAPPING_SCHEMA(data/schemas.py) is unchanged. New unit-test classTestMOFAndFacilityShareintests/unit/test_hierarchy.pycovers six scenarios: MOF parent inherits max-CCF childrisk_typeunder CRR; MOF picks OC over LR under Basel 3.1 (40% beats 10%); plain hierarchies with onlychild_type='loan'rows are not MOFs (parentrisk_typepreserved); facility share with three distinct corporate counterparties at CQS 1/3/5 allocates undrawn to CQS 5 (RW 150%); single-member facility is unchanged; combined MOF + share scenario applies both overrides independently. Full suite: 5,484 passed (1 skipped, 4 deselected) — no acceptance goldens shifted because no existing fixture combines a multi-CP facility share or a MOF with mixed-CCF children.scripts/arch_check.py,ruff check, andty checkall clean. Docs updated indocs/architecture/components.md(new key-features bullets and method rows in the HierarchyResolver section). Ref: CRR Art. 111 (SA CCFs); PRA PS1/26 Art. 111 Table A1; CRR Art. 112-122 (SA risk weights for the preview lookup). (PR #285.)
Changed¶
- Version bump for PyPI release.
Docs¶
- Doc batch (D2.* items, two
/next-docswaves, batchesb39d7e1andbdd0f31): eight regulatory-doc items landed in this window without code changes — Art. 143(2A)/(2B) A-IRB permission conditions (05660d0); Art. 154/157/158/160-166A/184 purchased-receivables pool & dilution risk (2ae4bf1); Art. 234 tranched coverage with P1.30(e) cross-ref (dd488c7); CRE loan-split threshold corrected from "60% LTV" to "55% of property value" (0d5a58d); Art. 119(2)/(3) CRR national-currency short-term institution coverage (1c4a339); OF 09.01 missing rows 0075/0085/0095/0141-0143/0160/0170 (fb0a5d2); plus twoIMPLEMENTATION_PLAN.mdticks (f83bc84,dd0f317). All edits are docs-only.
[0.2.1] - 2026-04-27¶
Added¶
is_airb_model_collateralflag on the collateral table to prevent AIRB collateral double-counting (CRR Art. 181 / Basel 3.1 Art. 169A): under A-IRB, the firm's own modelled LGD already reflects the credit-risk-mitigating effect of any collateral incorporated in the model, so allocating that same collateral to non-AIRB exposures of the counterparty supervisorily double-counts it. New optional Boolean column onCOLLATERAL_SCHEMA(defaultFalse) that asserts the row's collateral has been used to construct the firm's internal LGD model. The CRM allocator (engine/crm/collateral.py::_apply_collateral_unified) is now pool-aware: (1) exposures are partitioned at the start ofapply_collateralinto an AIRB pool (rows where the modelled LGD is preserved by CRM —approach == AIRBAND not falling back to the supervisory formula under Foundation election or Art. 169B insufficient-data) and a non-AIRB pool (FIRB / SA / Slotting and AIRB rows that use the formula); (2)_build_exposure_lookupsinengine/crm/processor.pynow emits pool-specific facility / counterparty EAD aggregates (_ead_facility_airb/_ead_facility_non_airb,_ead_cp_airb/_ead_cp_non_airb); (3) the group-by aggregation splits each metric into_n(unflagged collateral) and_a(flagged) variants via filter onis_airb_model_collateral; (4) pro-rata weights_fw_n/_fw_a/_cw_n/_cw_abake in the pool-match gate so non-matching pools contribute zero. Behaviour: flagged collateral routes only to AIRB-pool rows (facility / counterparty pro-rata over AIRB pool only); unflagged facility / counterparty collateral routes only to non-AIRB rows (AIRB pool excluded from the pro-rata base — non-AIRB rows now absorb 100% of unflagged counterparty / facility collateral that previously was wasted on AIRB rows whose modelled LGD ignores it); direct unflagged collateral is unchanged (1:1 to the named exposure). Direct flagged collateral pledged onto a non-AIRB exposure emits newCRM006(ERROR_AIRB_MODEL_COLLATERAL_MISDIRECTED) data-quality warning viafind_misdirected_airb_model_collateraland is given zero allocation. The inline_airb_uses_formulaexpression in_apply_collateral_unifiedwas lifted into a top-level helperairb_lgd_preserved_expr(config, is_basel_3_1, schema_names)reused by both the LGD branch and the new pool-membership tagging._apply_collateral_unifiedis defensive about missing pool-aware columns (test helpers that supply only_fac_ead_total/_cp_ead_totalare backfilled to legacy behaviour: AIRB total = 0, non-AIRB total = full total). Schema, processor lookups, and pro-rata logic are all backward-compatible with fixtures that don't set the column —ensure_columns(COLLATERAL_SCHEMA)in the loader (engine/loader.py:88) fills the default. Test coverage: newtests/unit/crm/test_airb_model_collateral_flag.py(7 tests across schema, unflagged-counterparty AIRB-exclusion, flagged-counterparty user scenarioloan_1/loan_2/loan_3, CRM006 misdirection emission and silence, homogeneous-FIRB backward-compat); benchmark data generator (tests/benchmarks/data_generators.py) updated to include the new column. Full suite: 5,505 passed, 1 skipped — no acceptance goldens shifted because no existing scenario combined mixed AIRB / non-AIRB exposures with counterparty / facility-level collateral. Docs updated indocs/specifications/crr/credit-risk-mitigation.md(new "Pool-Aware Pro-Rata for AIRB Mixes" subsection under Multi-Level Collateral Allocation) anddocs/specifications/basel31/credit-risk-mitigation.md("AIRB own-LGD anti-double-counting" bullet under Art. 191A(2)(d) anti-double-counting rules). Ref: CRR Art. 181; Basel 3.1 Art. 169A, Art. 191A(2)(d); CRE36.34-36. (PR #280.)
Changed¶
- Version bump for PyPI release.
Internal¶
- Experimental Claude agent-teams configuration for
loop.sh(PRs #282 / #283, commits0dcd821/94311fb/09a4b97/fbf784a): adds/next-docsand/next-itemsparallel batch orchestrators, a Phase 1 docs-implementation team, and full coverage of all fourloop.shmodes. Tooling-only; no engine or test changes.
[0.2.0] - 2026-04-26¶
Changed¶
- Promote
RESIDENTIAL_MORTGAGE/COMMERCIAL_MORTGAGEfrom uppercase magic strings to first-classExposureClassenum members: the SA real-estate loan-splitter (engine/re_splitter.py) labels its secured child row'sexposure_classwith one of two values that, until now, lived only as uppercase string literals (_SECURED_TARGET_RESIDENTIAL = "RESIDENTIAL_MORTGAGE"and_SECURED_TARGET_COMMERCIAL = "COMMERCIAL_MORTGAGE"inengine/classifier.py:158-159), with an explicit code comment noting they "are not in the ExposureClass enum (onlyRETAIL_MORTGAGEis)". The exposure-class enum convention is lowercase string values (e.g."retail_mortgage","corporate"); the loan-splitter therefore broke that convention every time it materialised a non-retail residential or commercial mortgage row. Behavioural impact: none (the SA RW expressions inengine/sa/namespace.pyuppercaseexposure_classvia_uc = pl.col("exposure_class").str.to_uppercase()before any substring match, so both cases route identically). Hygiene impact: the loan-splitter outputs are now indistinguishable from any other classifier output. Changes: (1) addExposureClass.RESIDENTIAL_MORTGAGE = "residential_mortgage"andExposureClass.COMMERCIAL_MORTGAGE = "commercial_mortgage"indomain/enums.pywith docstrings citing CRR Art. 125 / Art. 126 and PRA PS1/26 Art. 124F / Art. 124H; (2) replace the_SECURED_TARGET_*magic-string assignments inengine/classifier.py:158-159withExposureClass.<X>.valuereferences and rewrite the surrounding comment to point at the new enum members; (3) updateengine/re_splitter.pyto importExposureClassand replace the literal"COMMERCIAL_MORTGAGE"filter at line 503 withExposureClass.COMMERCIAL_MORTGAGE.value; (4) update all fourtarget_class=initialisers indata/tables/re_split_parameters.py(RE_SPLIT_PARAMS_CRR_RESIDENTIAL,RE_SPLIT_PARAMS_CRR_COMMERCIAL,RE_SPLIT_PARAMS_B31_RESIDENTIAL,RE_SPLIT_PARAMS_B31_COMMERCIAL) to use enum-value references via a newfrom rwa_calc.domain.enums import ExposureClass(the existing arch-check allowlist permitsdata/tables/to import fromdomain/enums, mirroring the pre-existingCQSimport incrr_risk_weights.py); (5) update test fixtures and assertions intests/unit/test_real_estate_splitter.py,tests/unit/test_b31_re_junior_charges.py,tests/integration/test_re_split_pipeline.py,tests/unit/test_b31_sa_risk_weights.py, andtests/unit/crr/test_crr_sa.pyto use the lowercase enum values, matching what the production classifier now emits. Intentionally not changed: (a) the uppercase"RESIDENTIAL_MORTGAGE"join key in thecrr_risk_weights.py:458LTV-split lookup table — it is joined against_lookup_classwhich is uppercased via.str.to_uppercase(), so it stays uppercase to preserve the case-insensitive routing behaviour; (b) theuc.str.contains("MORTGAGE", literal=True) | uc.str.contains("RESIDENTIAL", literal=True) | uc.str.contains("COMMERCIAL", literal=True) | uc.str.contains("CRE", literal=True)substring matches inengine/sa/namespace.py:_b31_append_real_estate_branchesand_crr_append_real_estate_branches— they cover both the canonical enum values and a real non-enum user-input path ("CRE"appears as a directexposure_classvalue intests/expected_outputs/crr/expected_rwa_crr.json:213for theLOAN_CRE_001acceptance fixture). Switching tois_in([ExposureClass.X.value.upper(), …])would lose that fallback unless the list reintroduced"CRE"as a magic string, defeating the refactor's purpose. Verification: 70 splitter-focused unit + integration tests pass (tests/unit/test_real_estate_splitter.py,tests/unit/test_b31_re_junior_charges.py,tests/integration/test_re_split_pipeline.py); 330 SA / classifier / data-boundary tests pass (tests/unit/test_b31_sa_risk_weights.py,tests/unit/crr/test_crr_sa.py,tests/unit/test_classifier.py,tests/contracts/test_data_layer_boundary.py);scripts/arch_check.pyclean; the broader acceptance + contracts + integration sweep was also run, and the failure count is identical to the parent commit baseline (106 acceptance failures, all pre-existing —hierarchy_resolvererrors in FIRB/AIRB/Slotting/Provisions scenarios that don't touch the SA real-estate path). Ref: CRR Art. 125, Art. 126; PRA PS1/26 Art. 124F, Art. 124H.
Fixed¶
- SME supporting factor
E*aggregated across group of connected clients (CRR Art. 501): the SA SME supporting factor previously evaluated the EUR 1.5m / GBP-equivalentE*exposure threshold on a per-counterparty basis, ignoring Art. 4(1)(39) connected-client aggregation. The check now aggregates across the full group of connected clients, matching the regulatory definition of the obligor used elsewhere in the pipeline. Acceptance scenarios CRR-F* updated; new test class intests/unit/test_supporting_factors.py. (PR #279)
Docs¶
- Clarify counterparty scope of the CRR Art. 125 35% / 75% residential mortgage RW: three doc updates document that CRR Art. 125 is not restricted to retail individuals — any exposure secured by qualifying residential property may receive the 35% secured / residual-counterparty-RW split, contingent on the Art. 125(2) qualifying conditions (value/repayment not materially dependent on borrower credit quality or property cash flows; Art. 208 / Art. 229(1) valuation). The calculator routes individuals via
RETAIL_MORTGAGE(inengine/classifier.pywhenis_mortgage=Trueandcp_entity_type=="individual") and non-retail counterparties viaRESIDENTIAL_MORTGAGE(through the SA real-estate loan-splitterengine/re_splitter.py); both paths apply the same Art. 125 split. (1)docs/user-guide/exposure-classes/retail.md— replaced the misleading "CRR uses a flat 35% (LTV ≤ 80%) or 75% (LTV > 80%)" sentence with the correct split treatment and added a pointer to the loan-splitter for non-retail RRE; (2)docs/framework-comparison/key-differences.md— added a "Counterparty scope of the CRR 35%/75% column" callout under the Residential RE General loan-splitting comparison table noting that the CRR column is regime treatment, not a retail-only label; (3)docs/specifications/crr/sa-risk-weights.md— extended the "Residential Mortgage Exposures (CRR Art. 125)" section with the Art. 125(2)(a)–(d) qualifying-conditions list (mirroring the existing Art. 126(2) list) and a "Counterparty scope" info admonition mapping the two routing buckets and noting that the calculator infers the qualifying-condition gate from theis_mortgageflag rather than independently verifying (a)–(c). No code changes.
[0.1.67] - 2026-04-25¶
Fixed¶
- Guarantor rating routing now beneficiary-aware (CRR Art. 161(3) / Basel 3.1 CRE22.70-85): guarantor substitution previously chose between the guarantor's internal PD and external CQS based purely on the guarantor's own properties — an SA exposure could end up routed through IRB parameter substitution, and an IRB exposure under CRR was always forced through SA RW substitution because parameter substitution was gated on
config.is_basel_3_1. Now the routing is keyed on the beneficiary's approach: IRB beneficiaries use the guarantor's internal PD when available; SA beneficiaries always use external CQS. The F-IRB supervisory LGD used in PD substitution and EL blending now tracks the active framework (0.45 CRR / 0.40 Basel 3.1) instead of being hard-coded. (PR #277)
Docs¶
-
Refresh
docs/data-model/input-schemas.mdto matchsrc/rwa_calc/data/schemas.py: the input schema reference had drifted ~30 columns behind the source of truth. Added documentation foreffective_maturity(CRR Art. 162(3) / PS1/26 numericMoverride that bypasses the 1-year floor) on Facility / Loan / Contingent; the Art. 110A due-diligence override fields (due_diligence_performed,due_diligence_override_rw); A-IRB modelled-EAD and unsecured-LGD fields (ead_modelled,lgd_unsecured,has_sufficient_collateral_data); maturity-floor and SFT flags (has_one_day_maturity_floor,is_sft); theis_payroll_loan35% retail RW flag; counterparty classification flags (is_natural_person,is_social_housing,is_financial_sector_entity,is_ccp_client_cleared,borrower_income_currency,local_currency,sovereign_cqs,institution_cqs); the Basel 3.1 real-estate collateral fields (is_qualifying_re,original_maturity_years,rental_to_interest_ratio,liquidation_period_days,qualifies_for_zero_haircut,is_main_index,insurer_risk_weight,credit_event_reduction); guarantee credit-derivative fields (protection_type,includes_restructuring); specialised-lendingproject_phase; equity / CIU look-through fields (ciu_approach,ciu_mandate_rw,ciu_third_party_calc,fund_reference,fund_nav); and an entirely new CIU Holdings schema section documenting the look-through input (Art. 132(3)). Also corrected severalRequired: Yescells that were stale relative to theColumnSpec(required=...)source of truth (Counterparty, Facility, Loan, Contingent — only the reference IDs andentity_typeare loader-required). No code changes. -
Comprehensive regulatory documentation refresh (D2.34–D2.73): ~30 documentation commits land verbatim citations and clarifications across A-IRB, CRM, slotting, real-estate, SCRA, covered bonds, and SL specs. Highlights: Art. 124B underwriting-standards obligation (D2.60); Art. 124D valuation requirements (D2.43, D2.62); Art. 124E(5)/(7) RE reassessment obligations (D2.56); Art. 122(7)-(8) output-floor election for unrated corporates (D2.46); Art. 129(4A) covered-bond due-diligence CQS step-up (D2.34); Art. 138(1)(g) + Art. 139(6) implicit-support higher-of rule (D2.49); Art. 153(5)(c)-(f) slotting column-assignment rules (D2.68); Art. 161(1)(e)/(f)/(g) purchased-receivables trigger recast (D2.51); Art. 162(2A)(k) revolving maturity precedence (D2.52); Art. 232(3) life insurance derivation (D2.48); Art. 237/239 CRM maturity-mismatch wording aligned to PS1/26 (D2.55); Art. 121(1)(a)/(1)(b) SCRA disclosure barring ladder (D2.54); BEEL substitution for A-IRB defaulted exposures (D2.67); retail A-IRB LGD floor reconciliation (D2.50); removal of Art. 119(2)/(3) national-currency preferential in B3.1 (D2.73). Misc fixes: USD 100bn LFSE threshold (D2.53); CRR FCSM Art. 222 paragraph attributions (D1.47, D2.63); LFSE citation to Art. 142(1)(4) (D1.49); MDBs/IOs in Art. 147A(1)(a) SA-only scope (D2.38). No code changes. (PR #275)
[0.1.66] - 2026-04-24¶
Added¶
effective_maturityoverride (CRR Art. 162(3) / PRA PS1/26): new optional column on Facility / Loan / Contingent that lets firms supply a numeric maturityMdirectly, bypassing the 1-year maturity floor and the date-derived calculation for IRB exposures. When populated, it takes precedence over derived maturity in correlation, K, and maturity-adjustment formulas. Documented indocs/data-model/input-schemas.md. (PR #274)
Fixed¶
- IRB / Slotting exposures secured by real estate no longer receive the 0.15 retail-mortgage correlation (CRR Art. 153 / CRE31.11):
RealEstateSplitter._split_unified_frameinengine/re_splitter.pypreviously split every row flagged by the classifier asre_split_mode='split'(or'whole') regardless of the row'sapproach, emitting a secured child row withexposure_class = RESIDENTIAL_MORTGAGE/COMMERCIAL_MORTGAGE. For FIRB / AIRB / Slotting rows, the IRB correlation expression inengine/irb/formulas.py::_correlation_expr_from_pdreadspl.col("exposure_class")and hits thestr.contains("MORTGAGE")branch →pl.lit(0.15), i.e. the retail-mortgage correlation under CRR Art. 154(3). A FIRB corporate-SME exposure collateralised by residential property was therefore splitting into (a) acorporate_smeresidual row with the correct supervisory-formula-with-SME-adjustment correlation and (b) aRESIDENTIAL_MORTGAGEsecured row stuck at 0.15 — a regime that doesn't exist under IRB. Loan-splitting is an SA-only regulatory mechanism (CRR Art. 125/126 and PRA PS1/26 Art. 124F/H all sit in the Credit Risk: Standardised Approach Part); IRB recognises real-estate collateral via LGD (Art. 161(5) FIRB supervisory RRE floor / AIRB own-estimate LGD / Art. 230-231 funded credit protection), already handled upstream by the CRM processor'scrm_alloc_real_estateallocation. Fix: gateis_split_modeandis_whole_modeonapproach ∈ {standardised, equity}(a new_SA_BOUND_APPROACHESmodule constant backed byApproachTypeenum values). Rows withapproach ∈ {foundation_irb, advanced_irb, slotting}and the classifier's split flag set now fall into the pass-through bucket and retain their originalexposure_class— the downstream IRB correlation formula then correctly lands on the corporate / corporate-SME / retail branches._accumulate_split_errorsis similarly gated so IRB rows do not emit SA-specificRE002zero-cap orRE004CRR rental-coverage warnings. When theapproachcolumn is absent (pure SA-only bundles, older test fixtures) the predicate defaults toTrue— existing SA-only tests continue to pass. 5 new regression tests intests/unit/test_real_estate_splitter.py::TestSplitterApproachGate(parametrised over FIRB/AIRB/Slotting pass-through, whole-loan pass-through, SA-with-explicit-approach still splits, no spurious RE002 on IRB zero-cap rows, mixed SA+IRB batch). Full suite: 4,640 unit passed, 810 acceptance+contracts+integration passed. Ref: CRR Art. 125, Art. 126, Art. 153(1)-(4), Art. 154(3), Art. 161(5), Art. 230-231; PRA PS1/26 Art. 124F, Art. 124H. - Defaulted SA exposures no longer return the base class RW when non-financial collateral columns are populated (PS1/26 Art. 127):
_apply_defaulted_risk_weightinengine/sa/namespace.pypreviously computedsecured_pct = (collateral_re_value + collateral_receivables_value + collateral_other_physical_value) / eadand blendedunsecured_pct × provision_rw + secured_pct × pl.col("risk_weight").pl.col("risk_weight")at that point is the exposure's base class RW (75% for retail, 100% for unrated corporate, 35% for mortgage), so whenever non-financial collateral reached or exceeded EAD, the blended RW collapsed back to the class base — for defaulted regulatory retail with RE collateral the SA path returned 75% instead of the Art. 127(1) 100%/150%. Art. 127(2) defers to the CRM method the institution applies (Art. 191A(2)); under FCCM (the default for SA) eligible financial collateral has already reducedead_finalupstream and eligible RE is routed through class reclassification, so the post-CRM value IS the unsecured portion and no secondary split is required inside the defaulted override. Fix: drop the non-financial collateral split entirely; apply the provision-based 100%/150% toead_finaldirectly (CRR denominator keeps the+ provision_deductedpre-provision reconstruction; B31 usesead_finalper "outstanding amount of the item or facility"). The Basel 3.1 RESI RE non-income-dependent flat-100% branch (Art. 127(3) / CRE20.88) and the HIGH_RISK precedence guard (Art. 128) are unchanged.tests/unit/test_defaulted_secured_split.pyrewritten to assert the new behaviour (regulatorily-incorrect "fully secured returns base RW" cases deleted; new regression tests pin down the retail scenario). New unit tests intests/unit/crr/test_crr_sa.py::TestDefaultedRWApplicationfor defaulted non-mortgage retail under both CRR and Basel 3.1. Acceptance scenario B31-K7 re-baselined (collateral columns no longer produce a blend). Specdocs/specifications/basel31/defaulted-exposures.mdupdated: FR-10.3 reworded to "unsecured portion determined by the CRM method"; D3.19 code-divergence warning on the B31 denominator removed (resolved); secured-portion section rewritten. Ref: PS1/26 Art. 127(1)-(3); Art. 191A(2); CRR Art. 127(1)-(2); CRE20.88-90.
[0.1.65] - 2026-04-21¶
Added¶
- Auto-sync of
config.eur_gbp_ratefrom the loadedfx_ratestable: the pipeline now keeps the scalar EUR/GBP rate used by the IRB SME correlation formula (CRR Art. 153(4)) and the GBP equivalents of EUR regulatory thresholds (RegulatoryThresholds.crr) in step with the(EUR, GBP)row of the loadedfx_ratesinput. Previously these two FX mechanisms were independent: a user could load an up-to-datefx_rates.parquetand get all exposure/collateral/guarantee/provision amounts converted at e.g. 0.90 while the IRB SME correlation and the derived GBP thresholds continued to run at the default 0.8732, silently. Implementation: (1) new modulesrc/rwa_calc/engine/fx_rate_sync.pyexposesextract_eur_gbp_rate(fx_rates: pl.LazyFrame | None) -> Decimal | None— returns the rate when the table contains exactly one(EUR, GBP)row, returnsNoneand logs WARNING"fx_rates table has N (EUR, GBP) rows; skipping eur_gbp_rate auto-sync"when multiple rows match; (2) new methodCalculationConfig.with_fx_rate(eur_gbp_rate)insrc/rwa_calc/contracts/config.pyusesdataclasses.replaceto produce a new config with botheur_gbp_rateandthresholds=RegulatoryThresholds.crr(eur_gbp_rate=...)rebuilt, so the SME turnover threshold, SME exposure threshold, retail max exposure, QRRE limit, and LFSE threshold are all re-derived at the new rate; the method is a no-op on Basel 3.1 (GBP-native per PRA PS1/26 Art. 153(4)) and a no-op when the rate is unchanged; (3)PipelineOrchestrator.run_with_datainsrc/rwa_calc/engine/pipeline.pycallsextract_eur_gbp_rate(data.fx_rates)immediately before_ensure_components_initialized(config)and, when the derived rate differs from the caller-supplied rate, logs WARNING"eur_gbp_rate auto-sync: replacing <old> with <new> from fx_rates table"onrwa_calc.engine.pipelineand swaps the localconfigviawith_fx_rate. New opt-out fieldCalculationConfig.sync_eur_gbp_rate_from_fx_table: bool = Truelets callers force their passed-in rate to win regardless of the data; when False, no WARNING is emitted and the supplied rate stands. Tests: 5 contract tests (tests/contracts/test_config.py::TestCalculationConfig—test_sync_eur_gbp_rate_flag_defaults_true,test_with_fx_rate_rebuilds_thresholds,test_with_fx_rate_noop_when_rate_unchanged,test_with_fx_rate_noop_for_basel_3_1,test_with_fx_rate_preserves_post_init_derivations); 6 unit tests (tests/unit/test_fx_rate_sync.py— single/missing/None/multiple rows, reverse-direction row, Decimal precision); 4 integration tests (tests/integration/test_fx_rate_autosync.py— divergence-warns-and-replaces, same-rate no-warn, opt-out suppresses, B3.1 no-op). Documented indocs/user-guide/methodology/fx-conversion.mdunder a new "Auto-sync ofeur_gbp_ratefrom the FX table" section covering the match rules, divergence warning, multiple-row skip, opt-out flag, and Basel 3.1 behaviour. Ref: CRR Art. 153(4);RegulatoryThresholds.crratcontracts/config.py:619.
Changed¶
- Refactor:
stage_timerlogging format enhanced for clearer pipeline traces. - Refactor: inline sidebar theme CSS for improved styling.
Fixed¶
- Retail Art. 123(c) threshold now aggregates across the full counterparty when no lending group is defined:
HierarchyResolver._enrich_with_lending_groupinengine/hierarchy.pypreviously setlending_group_total_exposureandlending_group_adjusted_exposureto0.0wheneverlending_group_referencewas null, and the classifier's fallback in_build_qualifies_as_retail_exprthen compared the per-rowexposure_for_retail_thresholdagainst the EUR 1m / GBP 880k limit. A counterparty with, say, three GBP 400k loans and no lending group was therefore classified as retail even though the aggregate GBP 1.2m exposure exceeded the threshold. CRR Art. 123(c) read with Art. 4(1)(39) ("group of connected clients") and PRA PS1/26 Art. 123A require aggregation across every exposure to a single obligor — a standalone counterparty is a group-of-one. Fix: the.otherwise(0.0)branches now aggregate via.sum().over("counterparty_reference")so both totals are always populated with the connected-client figure. The now-redundantzero_lending_group_failbranch inengine/classifier.pyis removed. New regression tests:tests/unit/test_hierarchy.py::TestLendingGroupAggregation::test_standalone_counterparty_aggregates_own_exposures(three-loan counterparty, 1.2m aggregate) andtests/unit/test_art123a_retail_criteria.py::TestCounterpartyAggregationWithoutLendingGroup(three cases covering above-threshold B3.1, below-threshold B3.1, and above-threshold CRR). Existingtest_standalone_not_in_lending_groupupdated to expect the counterparty aggregate (50k) rather than 0.0. Ref: CRR Art. 123(c), Art. 4(1)(39); PRA PS1/26 Art. 123A.
[0.1.64] - 2026-04-19¶
Added¶
- stdlib
loggingobservability layer (rwa_calc.observability): a new cross-cutting package (src/rwa_calc/observability/) configures stdlibloggingidempotently on therwa_calcnamespace logger (never root), installs acontextvars-backed correlationrun_idinjected onto every LogRecord, and providesstage_timer— a context manager that emits INFO"stage entered"/"stage completed"records with anelapsed_msextra (WARNING"stage failed"on exception). Every_run_*helper inPipelineOrchestratoris wrapped withstage_timer, so each pipeline run now emits matching entry/exit records for loader, hierarchy_resolver, classifier, crm_processor, re_splitter, calculators, aggregator, and equity_calculator, all sharing one freshly-generated 12-hex-charrun_idbound atrun_with_dataentry and cleared in the existingfinally. Two output formats —"text"(human-readable) and"json"(single-line, audit-friendly with a whitelisted extras set) — are selectable via two new fields onCalculationConfig(log_leveldefault"INFO",log_formatdefault"text") that also flow throughCreditRiskCalc(log_level=..., log_format=...)and both.crr()/.basel_3_1()factories.CreditRiskCalc.calculate()now callsconfigure_logging(config.log_level, config.log_format)before constructing the pipeline; the orchestrator itself does NOT callconfigure_loggingso it remains usable in embedded contexts. Noisy third-party loggers (polars,uvicorn.access,fastapi,asyncio) are pinned to WARNING. Contract: logging is operational-only — data-quality issues remain inCalculationError, and the integration test asserts no log record'smessageequals anyCalculationError.messagein the same run. Enforcement: ruff rulesG/LOG/T20(f-string lazy-formatting, deprecated API detection,print()ban withtests/**+ marimo apps exempted);scripts/arch_check.pygains check 8 (engine modules must declarelogger = logging.getLogger(__name__), noprint(orlogging.basicConfig(— helper modules listed inLOGGER_REQUIRED_EXEMPT);tests/contracts/test_logging_contract.pyasserts every stage module exports a correctly-namedLoggerand thatobservability.__all__is stable;tests/integration/test_logging_pipeline.pyruns the pipeline end-to-end and asserts entry/exit record pairs, sharedrun_id, distinct ids on back-to-back runs, no handler stacking, and no regulatory-error duplication. The ~19print()calls insrc/rwa_calc/ui/marimo/server.py:main()are converted tologger.infowithconfigure_logging("INFO", "text")called at startup. New specdocs/specifications/observability.mddocuments the public API, record schema, levels, correlation-ID lifecycle, reference stage skeleton, enforcement layers, and anti-patterns. CLAUDE.md gains a Logging section mirroring the Error Handling section.CalculationConfigfields are listed indocs/specifications/configuration.md(FR-5.7, CONFIG-7).
Changed¶
- Refactor: hoist SA risk-weight scalars and scaffold
lf.sanamespace (no behavioural change).
[0.1.63] - 2026-04-19¶
Added¶
- Real estate loan-splitter for SA exposures collateralised by property (CRR Art. 125/126, PRA PS1/26 Art. 124F/H): A new pipeline stage (
engine/re_splitter.py) inserted betweenCRMProcessorand the calculators physically partitions a property-collateralised non-RE SA exposure into two rows — a secured row reclassified toRESIDENTIAL_MORTGAGE/COMMERCIAL_MORTGAGEcapped at the regulatory secured-LTV cap, and an uncollateralised residual row that retains the original counterparty exposure class so the standard corporate / retail risk weight applies on the remainder. Both rows share asplit_parent_idlineage key so downstream aggregations reconcile back to the parent exposure. Previously, a corporate / retail loan secured by eligible property collateral that was not already classified as a mortgage received the full counterparty risk weight on the entire EAD, materially overstating capital. Mechanics are identical across regimes; parameters (secured LTV cap / secured RW / prior-charge reduction / counterparty carve-outs) live indata/tables/re_split_parameters.py(re_split_parameters(is_basel_3_1=...)): - CRR Art. 125 (RRE): secured cap = 80% LTV, secured RW = 35%, residual at counterparty CQS RW.
- CRR Art. 126 (CRE): secured cap = 50% LTV, secured RW = 50% — applied only when the rental coverage test (≥ 1.5× interest costs) is met (new optional input
rental_to_interest_ratioon collateral). When not met, no split is applied (RE004informational warning) and the exposure stays in its original class. Default conservative (no split) when the column is absent. - B3.1 Art. 124F (RRE): secured cap = 55% × property value (less prior charges per Art. 124F(2)), secured RW = 20%, residual at counterparty RW.
- B3.1 Art. 124H(1)-(2) (CRE NP/SME): secured cap = 55% × property value, secured RW = 60%, residual at counterparty RW. Restricted to natural persons / SMEs.
- B3.1 Art. 124H(3) (CRE other): no physical split; the whole exposure becomes a single
COMMERCIAL_MORTGAGErow so the existingb31_commercial_rw_exprArt. 124H(3) branch (max(60%, min(cp_rw, Art. 124I RW))) handles it.
The split is gated by a new classifier Phase 4c (_flag_property_reclassification_candidates) that emits re_split_target_class, re_split_mode ("split" / "whole" / null), re_split_property_type, re_split_property_value, and re_split_cre_rental_coverage_met candidate columns. Income-producing real estate continues to use the existing whole-loan path (Art. 124G / Art. 124I bands); already-classified RESIDENTIAL_MORTGAGE / RETAIL_MORTGAGE / COMMERCIAL_MORTGAGE / defaulted / equity / CIU / subordinated / high-risk / covered-bond rows are excluded from the split. The downstream SA RW expressions are reused unchanged — the secured row's LTV is capped by construction at the secured-LTV threshold, so the existing b31_residential_rw_expr / b31_commercial_rw_expr / CRR _apply_residential_mortgage_rw paths produce 35% / 50% / 20% / 60% naturally, and the residual row keeps its original exposure_class and gets the corporate / retail RW. Provisions allocate pro-rata by the EAD share. New audit LazyFrame CRMAdjustedBundle.re_split_audit captures one row per parent (parent EAD, secured/residual EAD, effective cap, target class, regime). New error codes: RE001 (non-eligible RE), RE002 (zero effective cap), RE003 (mixed property types), RE004 (CRR CRE rental coverage failed). New RealEstateSplitterProtocol in contracts/protocols.py. 14 new unit tests (tests/unit/test_real_estate_splitter.py), 3 end-to-end pipeline integration tests (tests/integration/test_re_split_pipeline.py), 2 protocol contract tests. Output floor / aggregator semantics unchanged: each child row contributes its own sa_rwa so portfolio-level totals are mathematically equivalent to the pre-split blended-RW row. Ref: CRR Art. 125, Art. 126(2)(d); PRA PS1/26 Art. 124A, Art. 124F, Art. 124F(2), Art. 124H(1)-(3), Art. 124L; SS10/13.
- Regression coverage: IRB-denied exposures must still use the counterparty's external ECAI rating on SA:
tests/integration/test_model_permissions_pipeline.py::TestIRBDeniedUsesExternalRatingOnSAadds three end-to-end tests that wire a counterparty with both an internal rating (PD +model_id) and an external rating (CQS) through the full pipeline, then assert that whenmodel_permissionsdeny IRB (viafilter_rejectedon exposure-class mismatch, viaunmatched_model_id, and via PRA PS1/26 Art. 147A(1)(a) sovereign SA-only routing) the resulting SA row carriesapproach="SA", the counterparty's externalcqs, and a CQS-basedrisk_weightrather than the unrated fallback — the scenario the CLS006 diagnostic warning already signals. A new_make_external_ratinghelper mirrors the existing_make_internal_ratingshape. These tests pin down the expected behaviour end-to-end; previously,tests/integration/test_model_permissions_pipeline.pyandtests/acceptance/basel31/test_scenario_b31_m_model_permissions.pyalways built internal-only ratings withcqs=None, so the external-rating path through SA after IRB denial was never exercised from rating inheritance throughSACalculator._apply_risk_weights.
Changed¶
- Institution guarantor RW expression unified: SA (
engine/sa/calculator.py::_apply_guarantee_substitution) and IRB (engine/irb/guarantee.py::_compute_guarantor_rw_sa) guarantee substitution paths had near-identical hard-codedpl.when().then()ladders for institution CQS → RW withpl.lit(0.30) if config.is_basel_3_1 else pl.lit(0.50)branches. Extracted shared helperbuild_institution_guarantor_rw_expr(cqs_col, is_basel_3_1)indata/tables/crr_risk_weights.pythat drives values fromINSTITUTION_RISK_WEIGHTS_CRR/INSTITUTION_RISK_WEIGHTS_B31_ECRAso the dicts remain the single source of truth and the two sites cannot drift on future edits. Also removed the deadextra_cols={"is_basel_3_1": ...}column previously emitted by_create_institution_df(never consumed by any downstream join).
Fixed¶
- RGLA / PSE institution-treated exposures now correctly route to IRB (CRR Art. 147(3)/(4)(b), PRA PS1/26 Art. 147A(1)(b)):
rgla_institutionandpse_institutioncounterparties carrying an internal rating were silently forced to SA regardless of IRB permissions. Under CRR the org-wideIRBPermissions.full_irb()map keys IRB eligibility offexposure_class, but the classifier setexposure_classfrom the SA map (RGLA / PSE) whilefull_irb()only listed CGCB / INSTITUTION / corporate / retail / SL — so everyrgla_*/pse_*row'sfirb_permitted_exprevaluated toFalseand fell through to the SA default. Under Basel 3.1 the_b31_sa_onlyfilter additionally sweptExposureClass.RGLA/ExposureClass.PSEinto the Art. 147A(1)(a) sovereign-only set, but Art. 147(3) scopes that restriction to quasi-sovereigns with 0% SA RW (i.e.rgla_sovereign/pse_sovereign/mdb/international_org) — institution-treated variants should follow the Art. 147A(1)(b) INSTITUTION F-IRB-only path. Fix inengine/classifier.py: (1)_build_orgwide_permission_exprsand_resolve_model_permissionsnow key their permission-match expressions onexposure_class_irb; (2)_b31_sa_onlynow keys oncp_entity_typewith the explicit Art. 147(3) list; (3)_b31_institution_no_airbnow keys onexposure_class_irb == INSTITUTION; (4) new Step 4a re-syncsexposure_class_irbwith the reclassifiedexposure_classafter Phases 3-4 (SME / QRRE / retail) so retail-reclassified corporates still match retail model permissions; (5) after approach assignment,exposure_classis rewritten toexposure_class_irbfor IRB-routedrgla_*/pse_*rows so the IRB calculator reads INSTITUTION / CGCB for correlation & LGD selection. SA-routed RGLA / PSE rows keepexposure_class = RGLA/PSEand continue to use Art. 115 / Art. 116 SA risk weight tables. Net effect under CRR: argla_institutionwith internal PD + modelled LGD now correctly lands on A-IRB via the INSTITUTION class; under B3.1 it lands on F-IRB with supervisory LGD per Art. 147A(1)(b). 11 new regression tests intests/unit/test_b31_approach_restrictions.py(TestCRRRGLAPSEIRBRoutingplus additionalTestB31QuasiSovereignSAOnlycases) cover CRR AIRB/FIRB routing, B3.1 FIRB routing, A-IRB blocking under B3.1, LGD clearing, and SA-fallback behaviour for unrated rgla/pse rows. Full suite: 4,711 unit passed, 627 acceptance + integration passed.IRBPermissions.full_irb_b31()permissions map unchanged (RGLA / PSE / MDB entries remain as defensive defaults); docstring updated to clarify the quasi-sovereign scope is tied to the 0%-RW entity treatment, not the SA exposure class label. Ref: CRR Art. 147(3), Art. 147(4)(b); PRA PS1/26 Art. 147A(1)(a), Art. 147A(1)(b) read with Art. 147(3). -
CRR Art. 138 multi-rating resolution now applied:
HierarchyResolver._build_rating_inheritance_lazyinengine/hierarchy.pypreviously collapsed multiple external ratings per counterparty to the single most recent one, silently ignoring assessments from additional nominated ECAIs. Replaced the "most recent wins" logic for external ratings with Art. 138: per-agency dedup (most recent per agency) followed by the 1-rating / 2-rating (higher RW) / ≥ 3-rating (second-best) selection rule. Resolution is performed on CQS rather than RW because within every SA exposure class the CQS → RW mapping is monotone non-decreasing. Internal-rating resolution, inheritance, and the external-rating non-inheritance rule are unchanged. NewTestArt138ExternalRatingResolutionclass intests/unit/test_hierarchy.pycovers single/two/three/four-rating cases, ties at the two lowest CQS, same-agency repeats, and null-CQS rows. Existing fixture counterparties have ≤ 1 external agency each, so no acceptance-golden changes. Ref: CRR Art. 138. -
CRR Art. 120(2) Table 4 short-term rated institution risk weights now applied [P1.99]: The CRR SA branch fell through to Art. 120 Table 3 (long-term) for every rated institution regardless of maturity, so a CQS 2 institution with 1-month residual maturity received the 50% long-term weight instead of the 20% Table 4 short-term weight. Added
INSTITUTION_SHORT_TERM_RISK_WEIGHTS_CRRindata/tables/crr_risk_weights.py(CQS 1-3 = 20%, CQS 4-5 = 50%, CQS 6 = 150%) and a new.when()branch inengine/sa/calculator.pykeyed onresidual_maturity_years <= 0.25withINSTITUTIONexposure class and non-null CQS. CRR Art. 120(2) keys on residual maturity and imposes no domestic-currency restriction (distinct from Art. 119(2)). Diverges from B31 Table 4 which applies 20% uniformly across CQS 1-5. - CRR Art. 121(3) unrated institution short-term 20% RW now applied [P1.121]: The CRR SA branch provided no short-term override for unrated institutions, so a 1-month-original-maturity unrated institution fell through to the Table 5 sovereign-derived fallback (typically 100%). Added
INSTITUTION_SHORT_TERM_UNRATED_RW_CRR = 0.20and a.when()branch inengine/sa/calculator.pykeyed onoriginal_maturity_years <= 0.25withINSTITUTIONexposure class and null-or-zero CQS. Art. 121(3) uses original effective maturity (consistent with the P1.133 B31 PSE/SCRA fix), so a seasoned 5-year bond with 1 month remaining does NOT qualify. Art. 121(6) sovereign floor (applied later via_apply_sovereign_floor_for_institutions) still lifts this to the sovereign weight for FX exposures. Capital previously overstated by up to 80 percentage points for short-term unrated interbank exposures. - Both fixes: 13 new regression tests in
tests/unit/crr/test_crr_institution_standard.py(TestCRRShortTermInstitutionTables,TestCRRShortTermInstitutionSACalculator) cover parametrized CQS 1-6 short-term rated, >3m fall-through, unrated short-term, original vs residual maturity keying, sovereign-floor interaction, and B31 isolation. 2 existing tests intests/unit/test_b31_sa_risk_weights.pythat previously asserted CRR does NOT apply short-term treatment renamed to assert the new correct behaviour. Full suite: 5,329 passed, 21 skipped. Ref: CRR Art. 120(1)-(2), Art. 121(3)/(6). - Short-term PSE/institution treatment now keys on original maturity [P1.133]:
SACalculator._SA_INPUT_CONTRACTextended withoriginal_maturity_years,value_date,maturity_date;calculate_branchderivesoriginal_maturity_yearsinline from(maturity_date - value_date)/365.0when the column is null, so hierarchy-supplied facility data flows through without a new schema column. Five SA call-sites updated to useoriginal_maturity_years: B31 PSE short-term (Art. 116(3)), B31 ECRA rated institution short-term (Art. 120(2)/(2A) incl. 6m trade-goods carve-out), B31 SCRA unrated institution short-term (Art. 121(3)), CRR PSE short-term (Art. 116(3)), and Art. 121(6) trade-goods sovereign-floor exception. Previously a 5-year bond with 1 month residual incorrectly attracted short-term 20% RW; it now correctly receives the long-term CQS/SCRA weight. 8 new regression tests cover seasoned-vs-fresh scenarios across both CRR and B31 branches intests/unit/test_pse_risk_weights.pyandtests/unit/test_b31_sa_risk_weights.py. Understates-capital bug; fix tightens RWs on seasoned short-residual exposures. Ref: CRR Art. 116(3), PRA PS1/26 Art. 120(2)/(2A), Art. 121(3)/(6). - PRA PS1/26 Art. 224 Table 1 B31 haircut corrections [P1.155]:
BASEL31_COLLATERAL_HAIRCUTSindata/tables/haircuts.pyhad 9 stale values (P1.155 originally cited 4; PDF verification found 5 more in the same table). Corrections verified against ps126app1.pdf p.203: sovereign CQS 2-3 3_5y 4%→3% and 10y+ 12%→6%; corp/institution CQS 1 1_3y/3_5y/5_10y 4/6/10%→3/4/6%; corp/institution CQS 2-3 1_3y/3_5y/5_10y/10y+ 6/8/15/15%→4/6/12/20%. The 10y+ CQS 2-3 correction (15%→20%) widens FCCM haircut on long-dated lower-rated corporate bonds (capital increase); the other 8 corrections were conservative over-haircuts whose correction reduces collateral haircut in FCCM, increases collateral value recognised, and lowers post-CRM EAD. Test classTestBasel31BondHaircutsintests/unit/crm/test_crm_basel31.pyparametrized into a singletest_b31_bond_haircuts_match_pra_table_1with 13 cases;test_b31_corp_bond_long_dated_higher_haircutinTestHaircutCalculatorFrameworkBranchingupdated accordingly. All 5,307 tests pass. - CRR Institution CQS 2 risk weight corrected to 50% (CRR Art. 120 Table 3) [P1.149]: The CRR table (misnamed
INSTITUTION_RISK_WEIGHTS_UK) conflated the PRA PS1/26 Basel 3.1 ECRA values (CQS 2 = 30%, unrated = 40%) with a non-existent "UK deviation" to CRR Art. 120, and the SA calculator keyed framework selection offbase_currency == "GBP"viause_uk_deviation. Under CRR Art. 120 Table 3, CQS 2 institutions are 50% and unrated institutions are 100% — no deviation exists in the UK-onshored CRR. Renamed data tables toINSTITUTION_RISK_WEIGHTS_CRRandINSTITUTION_RISK_WEIGHTS_B31_ECRA; replaceduse_uk_deviationboolean (keyed on base currency) withconfig.is_basel_3_1(keyed on framework) throughoutengine/sa/calculator.py,engine/irb/guarantee.py, andengine/equity/calculator.py. Also caught an additional instance of the same root-cause bug inirb/guarantee.py:269where unrated institution guarantors were hard-coded to 40% regardless of framework — now returns 100% under CRR and 40% under B31. CRR-A4 acceptance scenario updated (RW 0.30 → 0.50, RWA £300k → £500k); CRR-D4 updated (blended RW 0.58 → 0.70). Unit tests intests/unit/test_sovereign_floor_institutions.py,test_b31_sa_risk_weights.py,test_covered_bonds.py,test_guarantor_exposure_class_rw.py,crr/test_crr_sa.py,crr/test_crr_tables.py,crr/test_crr_institution_standard.py, andcrr/test_irb_namespace.pyupdated to reflect the correct CRR values. - HVCRE Good slotting EL rate corrected to 0.4% (PRA PS1/26 Art. 158(6) Table B) [P1.150]: Both
B31_SLOTTING_EL_RATES_HVCRE[GOOD](data/tables/b31_slotting.py) andSLOTTING_EL_RATES_HVCRE[GOOD](data/tables/crr_slotting.py) returned 0.8% — mirroring non-HVCRE long-maturity Good — but PRA PS1/26 Table B (Appendix 1 p.108) shows the HVCRE row flat at 0.4% across both Strong (cols A/B) and Good (cols C/D), i.e. HVCRE collapses the subgrade differentiation that non-HVCRE retains. Halves the EL shortfall for HVCRE Good exposures (capital overstatement when EL > provisions). Under UK CRR the substantive Article 158 was omitted by SI 2021/1078 in 2022, so PRA PS1/26 Table B is the only extant UK source — applied symmetrically to both framework data tables. Updated unit tests intests/unit/test_slotting_el_rates.py(renamedtest_hvcre_good_zero_point_eight→test_hvcre_good_zero_point_fourfor both CRR and B31, replacedtest_hvcre_matches_long_maturity_non_hvcrewithtest_hvcre_good_diverges_from_non_hvcre_long_maturityregression guard, parametrized cases("good", True, False, ...)and("good", True, True, ...)now expect 0.004). Updated B31 slotting spec admonition. Acceptance scenarios CRR-E4/E7/E8 unchanged (they assert HVCRE risk weights, not EL rates). - FX haircut on collateral silently zero after FX conversion (CRR Art. 224, PRA PS1/26 Art. 224) [P1.135/P1.136]:
FXConverter.convert_exposures()andconvert_collateral()both rewrite thecurrencycolumn to the reporting currency, so by the timeHaircutCalculator.apply_haircutscomparedcurrency != exposure_currencyboth sides were equal and the 8% Art. 224 FX volatility haircut was silently never applied to any FX-mismatched secured exposure (HIGH capital understatement). Root cause: onlyconvert_exposuresandconvert_guaranteespreservedoriginal_currency;convert_collateral,convert_provisions, andconvert_equity_exposuresdid not — and_build_exposure_lookupssourced the collateral-sideexposure_currencyfrom the post-conversioncurrency. Fix: (1)engine/fx_converter.py— all four sibling converters now aliascurrencyintooriginal_currencyon both the conversion and no-conversion paths; (2)engine/hierarchy.py— removed theapply_fx_conversion/fx_rates is not Nonebranching block (converters now handle the no-op path consistently); (3)engine/crm/processor.py::_build_exposure_lookups— prefersoriginal_currencywith fallback tocurrency; (4)engine/crm/haircuts.py::apply_haircuts— compares the collateral'soriginal_currencywith fallback. Regression coverage: 5 tests intests/unit/crm/test_collateral_fx_mismatch.py(including the post-conversion pipeline path that the existing scalar-basedcalculate_single_haircuttests did not exercise) + 2 tests intests/unit/test_fx_converter.pycovering the new collateral audit column. - Domestic sovereign guarantor 0% RW uses guarantee currency (CRR Art. 114(4)/(7)): The Art. 114(4)/(7) domestic-currency test on a guaranteed portion was being evaluated against the underlying exposure's currency rather than the guarantee's currency, which meant a GBP loan guaranteed by an EU sovereign in that sovereign's domestic currency (e.g. DE in EUR) did not receive 0% RW even though, under the substitution approach (Art. 215-217), the substituted claim against the sovereign is denominated in EUR. The Art. 233(3) 8% FX haircut already handles the cross-currency layer between guarantee and underlying loan; layering Art. 114(4)/(7) on top of the exposure currency effectively nullified Art. 233(3) for sovereign guarantees. Switched the three call sites that implement the check (
engine/crm/guarantees.pyrouting,engine/irb/guarantee.py::_compute_guarantor_rw_sa,engine/sa/calculator.py::_apply_guarantee_substitution) to readguarantee_currency(already populated on guaranteed rows by_apply_guarantee_splits) with a null-safe fallback to the exposure'sdenomination_currency_expr. Added shared helperbuild_domestic_cgcb_guarantor_exprindata/tables/eu_sovereign.pycombining the UK and EU-member branches into a single expression so the three sites cannot drift. New regression coverage:tests/integration/test_domestic_sovereign_guarantor_end_to_end.py(4 end-to-end cases through the full pipeline), a newTestGuarantorSubstitutionReadsGuaranteeCurrencyclass intests/unit/test_guarantor_exposure_class_rw.py(5 cases covering the cross-currency SA+IRB substitution), and an extendedTestDomesticSovereignGuarantorForcedToSAintests/unit/crm/test_guarantor_rating_type.pyadding the reported GBP-loan/EUR-guarantee/DE-sovereign case plus a guard against reading exposure currency (EUR loan + GBP guarantee + DE sovereign must stay IRB). Applies under both CRR and Basel 3.1 / PRA PS1/26. - Domestic sovereign guarantor 0% RW vs internal rating (CRR Art. 114(4)/(7)): When a guarantee from an EU/UK central government/central bank in its domestic currency was provided by a counterparty that the firm rates internally (i.e. carries an
internal_pd) and the firm holds IRB permission for the CGCB exposure class, the guarantor was being routed to the IRB substitution path. The downstream_apply_parameter_substitutionstep inengine/irb/guarantee.pythen overwrote the SA branch's correct 0% RW with the parametric F-IRB risk weight derived from the PD, so e.g. a DE sovereign + EUR guarantor withinternal_pd = 0.001produced ~2.6% instead of the regulatory 0%. The previous EU/UK domestic 0% fix (PR #253) handled the FX-conversion edge case but only inside the SA branch — it did not change routing, so internal-PD guarantors bypassed it. Promoted the Art. 114(4)/(7) check into the guarantor-approach routing step inengine/crm/guarantees.py: domestic-currency CGCB guarantors are now forced toguarantor_approach = "sa"ahead of the internal-PD branch, so the existing SA 0% short-circuit fires regardless of whether the guarantor has an internal rating. Theguarantor_rating_typeaudit field is unchanged — still reports"internal"when an internal PD exists, since the override is an approach decision, not a rating-source decision. Reusesbuild_eu_domestic_currency_expranddenomination_currency_expr(post-FX safe). Applies under both CRR (Art. 114(4) UK/GBP, Art. 114(7) EU member states) and Basel 3.1 (PRA PS1/26 preserves Art. 114(7) by cross-reference via third-country reciprocity). AddedTestDomesticSovereignGuarantorForcedToSAregression class intests/unit/crm/test_guarantor_rating_type.pycovering UK/GBP, DE/EUR (post-FX), PL/PLN (non-euro EU) and a non-domestic DE/USD counter-case under both frameworks.
[0.1.62] - 2026-04-17¶
Changed¶
- Version bump for PyPI release
[0.1.61] - 2026-04-15¶
Fixed¶
- EU sovereign guarantee 0% RW (CRR Art. 114(4)): Exposures guaranteed by an EU member state central government/central bank in that state's domestic currency (e.g. a German sovereign guaranteeing a EUR-denominated exposure) were failing to receive the mandated 0% risk weight when the pipeline's FX converter was active. Root cause:
engine/fx_converter.pyoverwrites the exposure'scurrencycolumn with the reporting currency and stores the pre-conversion denomination inoriginal_currency, but every downstream "denominated in domestic currency" check read the now-overwrittencurrencycolumn. After FX conversion a DE sovereign + EUR exposure appeared as DE + GBP (or whatever the reporting currency was), so the Art. 114(4) short-circuit never fired; unrated EU sovereign guarantors then fell through to.otherwise(1.0)= 100% instead of 0%. Addeddenomination_currency_expr()helper indata/tables/eu_sovereign.pythat returnspl.col("original_currency")when present, elsepl.col("currency"). Extendedbuild_eu_domestic_currency_exprto accept apl.Exprfor the currency side. Updated all seven affected call sites inengine/sa/calculator.py(borrower + guarantor),engine/irb/guarantee.py(guarantor, IRB path), andengine/classifier.py(forced-SA check for EU domestic sovereigns). ExistingTestSAEUDomesticSovereignTreatmentunit tests were bypassing the bug because they fabricated LazyFrames withcurrencyset to the denomination directly; addedTestSAEUDomesticSovereignPostFX/TestIRBEUDomesticSovereignPostFXregression classes covering the post-FX pipeline state.
[0.1.60] - 2026-04-14¶
Changed¶
- Data tables: Eliminated duplicated regulatory values in
data/tables/. Previously most_create_*_dfbuilders hardcoded numeric literals that were already defined in the module's constant dicts (CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS,CORPORATE_RISK_WEIGHTS,COLLATERAL_HAIRCUTS,BASEL31_FIRB_SUPERVISORY_LGD, etc.), meaning a regulatory update required changes in 2+ places. Builders now derive their values by iterating the authoritative dict: new helpers_build_cqs_rw_df(crr),_build_int_cqs_rw_df(b31),_build_haircut_df(haircuts), and_build_firb_lgd_df/_build_b31_firb_lgd_df(firb_lgd) read values from the dicts via small row-spec tuples that define column ordering.B31_FIRB_LGD_*scalar aliases now derive fromBASEL31_FIRB_SUPERVISORY_LGD. Matches the gold-standard pattern already used inb31_equity_rw.py. New testtests/unit/test_tables_dict_dataframe_parity.py(18 cases) locks in the invariant so regressions cannot reintroduce duplication. DataFrame schemas, column/row ordering, and public API are unchanged — no regulatory values changed. - Data tables: Renamed
data/tables/crr_haircuts.py->data/tables/haircuts.pyanddata/tables/crr_firb_lgd.py->data/tables/firb_lgd.py; both files already held dual-framework content (CRR Art. 224/161 and PRA PS1/26 equivalents), so thecrr_prefix was misleading. Mergeddata/tables/b31_firb_lgd.pyintofirb_lgd.py— it was a thin re-export of the Basel 3.1 LGD dict that physically lived in the CRR-prefixed file. AllBASEL31_*/B31_*constants, lookup helpers (lookup_b31_firb_lgd,get_b31_firb_lgd_table,get_b31_vs_crr_lgd_comparison), and framework-shared helpers (FIRB_OVERCOLLATERALISATION_RATIOS,FIRB_MIN_COLLATERALISATION_THRESHOLDS,CRR_K_SCALING_FACTOR) are now infirb_lgd.py. Module docstrings updated to reflect dual-framework scope;crm_supervisory.pydocstring updated to match. Import sites updated acrossengine/, tests, and docs; publicdata/tables/__init__.pyre-exports preserved. No regulatory values changed.
[0.1.59] - 2026-04-14¶
Changed¶
- Version bump for PyPI release
[0.1.58] - 2026-04-11¶
Fixed¶
- Guarantees (#239): Fixed two bugs in multi-guarantor handling:
- Non-beneficial guarantors consuming EAD: When an exposure has multiple guarantors and some are non-beneficial, the pro-rata scaling no longer wastes EAD on non-beneficial guarantors. After the SA/IRB beneficial check, a new
redistribute_non_beneficial()function reallocates freed portions to beneficial guarantors using a greedy strategy ordered by ascending risk weight (lowest RW fills first), minimising total RWA. - FX/restructuring haircuts applied after capping: The 8% FX mismatch haircut (Art. 233(3-4)) and 40% CDS restructuring exclusion haircut (Art. 233(2)) are now applied to the nominal credit protection value (G) before capping at EAD, per CRR Art. 233/235. Previously, a large cross-currency guarantee that vastly exceeded EAD would incorrectly have coverage reduced (e.g. £200m guarantee on €1m loan → was 920k, now correctly 1m).
- CCF (P1.166): CRR OC (Other Commitments) CCF corrected from 0% to maturity-dependent values. Under CRR, the OC category did not exist — commitments were classified by maturity: >1yr → MR (50% SA / 75% F-IRB), ≤1yr → MLR (20% SA / 75% F-IRB). The only 0% category was LR (unconditionally cancellable). Previously understated capital for all OC-tagged exposures under CRR. SA CRR: OC now receives 50% (>1yr) or 20% (≤1yr, based on maturity_date vs reporting_date); 50% conservative default when maturity_date absent. F-IRB CRR: OC moved from 0% to 75% (both MR and MLR are 75% under F-IRB). Basel 3.1 OC (40%) unchanged. Updated
sa_ccf_expression(),_firb_ccf_for_col(), and_compute_ccf()with maturity-aware override. Spec F-IRB table corrected. 7 unit tests updated, 6 new tests added. - Equity (P1.132): B31 government-supported equity risk weight corrected from 100% to 250% per Art. 133(3). Art. 133(6) is an exclusion clause (own funds deductions, Art. 89(3), Art. 48(4)), not a 100% risk weight — CRR Art. 133(3)(c) legislative equity carve-out has no equivalent in B31. Previously understated capital by 2.5x for government-supported equity under B31. Government-supported equity also removed from transitional floor exclusion (now subject to floor as standard equity, though 250% already exceeds all transitional floors). Updated risk weight table, calculator, transitional floor logic, 8 unit tests, 4 acceptance tests, and spec documentation. Art. 133 paragraph references corrected across codebase (subordinated debt = Art. 133(5) not 133(1); PE/VC = Art. 133(4) not 133(5)).
- Covered Bonds (P1.113): B31 rated covered bond risk weights corrected from BCBS CRE20.28 values to PRA PS1/26 Art. 129(4) Table 7 values. CQS 2: 15%→20%, CQS 6: 50%→100%. PRA retained CRR Table 6A unchanged — did NOT adopt BCBS reductions. Previously understated capital for CQS 2 and CQS 6 covered bonds. Both
B31_COVERED_BOND_RISK_WEIGHTSdict and_create_b31_covered_bond_df()DataFrame corrected. All 77 covered bond tests updated. 3 stale doc divergence warnings converted to "Fixed" admonitions. - Equity (P1.119): CIU fallback risk weight corrected from 150% (CRR) / 250%-400% (B31) to 1,250% per Art. 132(2). Was the highest-severity capital understatement bug (3-8x). Root cause: original implementation used Art. 133 equity risk weights instead of Art. 132(2) punitive CIU fallback. Extracted shared
CIU_FALLBACK_RWconstant and_append_ciu_branches()helper to eliminate CRR/B31 code duplication. Updated risk weight tables, calculator, 27 unit tests, 7 acceptance tests, and both equity spec documents.
[0.1.57] - 2026-04-11¶
Changed¶
- Naming: Renamed functions with "and" in their names to better reflect single responsibility:
_classify_sme_and_retail->_classify_exposure_subtypes(classifier)_determine_approach_and_finalize->_assign_approach(classifier)_sink_and_scan->_spill_to_disk(materialise)_combine_irb_and_slotting->_merge_el_sources(EL summary aggregator)commit_and_push->publish_changes(git ops)- Classifier: Moved
B31_LARGE_CORPORATE_REVENUE_THRESHOLD_GBP(PRA PS1/26 Art. 147A(1)(e)) andB31_SME_TURNOVER_THRESHOLD_GBP(PRA PS1/26 Art. 153(4)) fromengine/classifier.pytodata/tables/b31_risk_weights.pyfor consistency with other B31 regulatory thresholds. Converted fromfloattoDecimal. - Pipeline: Renamed private methods in
PipelineOrchestratorto remove stale fan-out/single-pass terminology:_run_crm_processor_unified->_run_crm_processor,_run_single_pass->_run_calculators,_aggregate_single_pass->_aggregate_results. Section header renamed from "Single-Pass Pipeline" to "Calculation". - Pipeline: Removed dead code
_run_sa_calculatorand_run_irb_calculator(never called from production; superseded bycalculate_branch()in the single-pass path). Associated tests removed.
[0.1.56] - 2026-04-11¶
Changed¶
- Version bump for PyPI release
[0.1.55] - 2026-04-09¶
Fixed¶
- Classifier: Exposures with internal ratings no longer silently route to Standardised Approach when
permission_mode="irb"is set onCreditRiskCalc. Two independent bugs are addressed: - Pipeline downgrade (Bug #1):
PipelineOrchestrator.run_with_datauseddataclasses.replace(config, permission_mode=STANDARDISED)whenmodel_permissionswas absent, which re-ranCalculationConfig.__post_init__and wipedirb_permissionstosa_only(). The pipeline now preserves the user's org-wide IRB permissions and emits amissing_model_permissionspipeline error explaining that per-model gating is disabled. - Silent classifier join failure (Bug #2):
ExposureClassifier._resolve_model_permissionsjoinedexposure.model_idLEFT againstmodel_permissions.model_id. Null or unmatchedmodel_idvalues produced no match and silently routed to SA with no diagnostic. The classifier now tags each IRB-eligible miss with one of three causes (null_model_id,unmatched_model_id,filter_rejected) and emits a rolled-upCLS006(ERROR_MODEL_PERMISSION_UNMATCHED) classification warning per cause with targeted remediation guidance. - Tests: Added
TestModelPermissionsDiagnostics(4 integration tests) andTestPipelineIRBWithoutModelPermissions(1 integration test) intests/integration/test_model_permissions_pipeline.py, plus a regression guardtest_irb_mode_preserves_full_irb_after_pipeline_initintests/unit/test_irb_approach_selection.py. - Docs: Replaced fabricated double-default formula in
crm.mdwith correct CRR Art. 153(3) formulaK_dd = K_obligor × (0.15 + 160 × PD_guarantor)(D3.7). Added eligibility requirements (Art. 202/217), guarantor RW floor, and Basel 3.1 removal warning with cross-link to A-IRB spec. - Docs: SA specialised lending waterfall position documented in
key-differences.md(D2.20). Waterfall item 15 annotated with Art. 122–122B SA SL sub-classification cross-reference. New admonition added explaining SA SL sits within corporates (row 15, Art. 112(1)(g)), with IPRE excluded per Art. 122A(1) ("not a real estate exposure") — IPRE is caught at row 7 (real estate, Art. 124–124L) instead. SA SL section expanded with: - Art. 122A(1) 4-part definition criteria (SPV structure, asset dependency, lender control, asset income repayment)
- Art. 122A(2) sub-type classification (OF, CF, PF)
- IPRE exclusion warning admonition with cross-reference to real estate section
- Art. 122B(1) rated SL fallthrough to corporate CQS table
- Art. 122B(2) unrated risk weight table with article references per row
- Art. 122B(3) operational phase definition (positive net cash-flow + declining LT debt)
- Art. 122B(4)–(5) high-quality PF criteria (8 structural conditions)
[0.1.54] - 2026-04-08¶
Added¶
- COREP: Reporting basis conditionality for output floor (P1.38(c)).
COREPGeneratornow acceptsoutput_floor_config: OutputFloorConfigto gate floor-related COREP template content on entity-type applicability per Art. 92 para 2A: - OF 02.00 rows 0034-0036 (floor activated/multiplier/OF-ADJ) show 0.0 for exempt entities (international subsidiaries, ring-fenced bodies on individual basis, etc.)
- OF 02.01 (output floor comparison) returns None for exempt entities — only applicable entities report the floor comparison
- C 08.07 materiality columns 0160-0180 documented as consolidated-basis-only (Art. 150(1A)), threaded with
is_consolidatedflag for future population - COREPTemplateBundle extended with
reporting_basisandinstitution_typemetadata fields - ResultExporterProtocol and ResultExporter accept
output_floor_configkeyword parameter - Tests: 38 new tests in
tests/unit/test_corep_reporting_basis.pyacross 7 test classes: COREPTemplateBundleMetadata (7), OF0201FloorApplicability (6), OF0200FloorIndicatorRows (7), C0807MaterialityColumns (4), BackwardCompatibility (3), EntityTypeCombinations (9 parametrized), ExporterProtocolCompliance (2). Total: 5,125 (was 5,087). Contract tests: 145. - Tests: 26 new tests in
tests/unit/crm/test_equity_main_index.pyacross 7 test classes: schema validation, CRR/B31 haircut verification for main-index and other-listed, backward compatibility, precedence over eligibility flag, mixed collateral, and full pipeline end-to-end (other-listed EAD = 625k vs main-index EAD = 575k on 1M exposure with 500k equity collateral). Total: 5,087 (was 5,061). - Tests: 36 new CRM acceptance tests in
tests/acceptance/crr/test_scenario_crr_d2_crm_advanced.pyacross 13 test classes covering advanced CRM scenarios not tested by the basic D1-D6/G1-G3 groups: non-beneficial guarantee (guarantor RW = borrower RW), sovereign guarantee 0% substitution, CDS restructuring exclusion (40% haircut, Art. 216(1)/233(2)), CDS with restructuring (no haircut contrast), gold collateral (15% CRR haircut), equity collateral (main-index 15%), overcollateralisation (EAD=0), full CRM chain (provision+collateral+guarantee), mixed collateral types (cash+bond), SA provision EAD deduction, multiple provisions summed, provision+collateral combined, and structural baseline validation. CRR acceptance: 169 (was 133). Total: 5,061 (was 5,025). (P5.3) - COREP: C 09.01 / OF 09.01 — CR GB 1 geographical breakdown SA. One DataFrame per country code + TOTAL. CRR: 13 columns (0010-0090 incl. supporting factors), 23 rows. Basel 3.1: 10 columns (removes supporting factors), 29 rows (adds SL sub-rows 0071-0073, RE sub-rows 0091-0094, removes short-term row). Uses
cp_country_codefrom counterparty schema. Template definitions, generator methods, class maps, framework selectors. - COREP: C 09.02 / OF 09.02 — CR GB 2 geographical breakdown IRB. One DataFrame per country code + TOTAL. CRR: 17 columns (incl. PD, LGD, EL, supporting factors), 16 rows (incl. equity). Basel 3.1: 15 columns (adds 0107 defaulted EV, removes supporting factors), 19 rows (adds corporate sub-rows, restructures retail RE, removes equity).
- Tests: 80 new COREP tests for C 09.01/09.02 across 10 test classes. COREP tests: 635 (was 555). Total: 4,953 (was 4,873). (P2.3)
- COREP: C 08.04 / OF 08.04 — CR IRB RWEA flow statements. 1 column (RWEA) × 9 rows (opening, 7 movement drivers, closing) per IRB exposure class. Closing RWEA (row 0090) populated from pipeline; opening and drivers null (require prior-period data). Slotting excluded. CRR column names "after supporting factors"; Basel 3.1 removes supporting factors reference. Template definitions:
CRR_C08_04_COLUMNS,B31_C08_04_COLUMNS,C08_04_ROWS,C08_04_COLUMN_REFS,get_c08_04_columns(). Generator:_generate_all_c08_04(),_generate_c08_04_for_class().COREPTemplateBundle.c08_04field (dict[str, pl.DataFrame]). Excel export with C 08.04 / OF 08.04 prefix. - Tests: 41 new COREP tests for C 08.04 across 6 test classes (TestC0804TemplateDefinitions: 13, TestC0804Generation: 5, TestC0804ClosingRWEA: 4, TestC0804NullDriverRows: 9, TestC0804B31Features: 3, TestC0804EdgeCases: 7). COREP tests: 555 (was 514). (P2.2)
- Pillar III: UKB CR9 — IRB PD backtesting per exposure class (Art. 452(h)). 8 columns × 17 PD buckets + total row. Basel 3.1 only. Separate F-IRB and A-IRB template sets. Uses
irb_pd_originalfor bucket allocation (beginning-of-period proxy). Includes obligor count, default count, observed default rate, EAD-weighted average PD, arithmetic mean PD, historical annual default rate. - Pillar III: UKB CR9.1 — ECAI mapping PD backtesting (Art. 180(1)(f)). Template definitions only; generation deferred until pipeline provides firm-specific ECAI mapping data.
- Pillar III:
Pillar3TemplateBundle.cr9field added (dict of approach–class keyed DataFrames) - Pillar III: CR9 Excel export via
export_to_excel()with human-readable sheet names (e.g., "UKB CR9 F-IRB Corp") - Tests: 44 new tests for CR9/CR9.1 across 7 test classes (definitions, generation, column values, PD allocation, edge cases, bundle integration, Excel export). Total: 4,832 (was 4,788). (P3.2)
Fixed¶
- Docs: Art. 128 (high-risk items, 150%) UK CRR omission clarified across 6 files (D1.28, D4.9). Art. 128 was omitted from UK onshored CRR by SI 2021/1078, reg. 6(3)(a), effective 1 January 2022 — the high-risk exposure class is a dead letter under current UK CRR. Re-introduced under PRA PS1/26 (Basel 3.1, from 1 January 2027) with paragraphs 1 and 3 retained (paragraph 2 left blank). Files updated:
specifications/crr/sa-risk-weights.md: Added omission admonition, B31 re-introduction note, code bug cross-reference (D3.12), and exposure class waterfall clarification (equity priority 3 > high-risk priority 4)user-guide/exposure-classes/other.md: Restructured "Items Associated with High Risk" section — added framework applicability warning, corrected table to Art. 128 items only (speculative RE, PRA-designated), added waterfall note explaining PE/VC are equity (Art. 133), not high-riskframework-comparison/key-differences.md: Corrected equity table row (removed "(or 150% if Art. 128 high-risk)" — PE/VC is equity per waterfall), added Art. 128 re-introduction admonition to priority waterfall sectionuser-guide/regulatory/crr.md: Added "Omitted Provisions" section documenting Art. 128 and Art. 132 omissions by SI 2021/1078specifications/crr/equity-approach.md: Corrected Art. 128 note to explain waterfall precedence (equity > high-risk) and UK CRR omissionspecifications/common/hierarchy-classification.md: Updated calculator coverage note with Art. 128 framework status and CRR legal basis issue- Docs: Documentation accuracy sweep correcting wrong regulatory values across 13 files (P4.5, P4.6, P4.22):
- PD floors (P4.5): Retail mortgage Basel 3.1 PD floor corrected from 0.05% to 0.10% (Art. 163(1)(b)) in 5 files. QRRE transactor Basel 3.1 PD floor corrected from 0.03% to 0.05% (Art. 163(1)(c)) in 5 files. Affected:
api/configuration.md,user-guide/configuration.md,user-guide/exposure-classes/retail.md,data-model/regulatory-tables.md. - LGD floors (P4.6): Corporate LGD floor code example corrected (RECEIVABLES 15%→10%, CRE 15%→10%, OTHER_PHYSICAL 20%→15%) in
user-guide/configuration.md. Corporateresidential_real_estatefield corrected from 0.05 to 0.10 (Art. 161(5)) inapi/configuration.md— was showing retail floor instead of corporate floor. - Output floor schedule (P4.22): BCBS 6-year schedule (50%/55%/60%/65%/70%/72.5%, 2027–2032) replaced with PRA 4-year schedule (60%/65%/70%/72.5%, 2027–2030) across 12 files. Affected:
plans/implementation-plan.md,api/engine.md,api/contracts.md,framework-comparison/reporting-differences.md,plans/prd.md,specifications/index.md,features/index.md,specifications/regulatory-compliance.md,framework-comparison/index.md,appendix/index.md,framework-comparison/impact-analysis.md,user-guide/configuration.md. - CRM: Decoupled
is_main_indexfromis_eligible_financial_collateralfor equity collateral haircuts (P6.21). Addedis_main_indexBoolean field toCOLLATERAL_SCHEMA. When present, drives haircut lookup directly:True= main-index (CRR 15%, B31 20%),False= other-listed (CRR 25%, B31 30%). When absent, falls back tois_eligible_financial_collateralfor backward compatibility. Previously all eligible equity was forced to the main-index haircut tier. - COREP: OF 02.00 IRB sub-row splits — rows 0295-0297 (FSE/large, SME, non-SME corporates), 0355-0356 (retail RE SME/non-SME), 0382-0385 (corporate RE sub-splits), 0400/0410 (other retail SME/non-SME) now populated from pipeline data instead of hardcoded 0.0. Uses finer-grained aggregation keyed by (approach, exposure_class, is_sme, apply_fi_scalar, property_type).
- COREP: OF 02.00 floor indicator rows 0035/0036 — floor_pct and of_adj now populated from
OutputFloorSummarywhen provided, instead of hardcoded 0.0. - COREP:
_filter_re()fallback chain — gracefully degrades frommaterially_dependent_on_property→has_income_cover→is_income_producingwhen pipeline columns vary. Null handling corrected: only fallback columns usefill_null(False), preserving null-as-unclassified semantics for the primary column. - Equity:
_apply_transitional_floor()now emitsequity_transitional_approachandequity_higher_riskannotation columns for COREP OF 07.00 rows 0371-0374. - Tests: 24 new COREP tests across 4 classes (IRB sub-row splits, floor indicators, RE fallback, equity transitional columns). COREP tests: 687 (was 663). Total: 5,025 (was 5,001). (P2.5)
[0.1.53] - 2026-04-07¶
Changed¶
- Version bump for PyPI release
[0.1.52] - 2026-04-06¶
Changed¶
- Version bump for PyPI release
[0.1.51] - 2026-04-05¶
Changed¶
- Version bump for PyPI release
[0.1.50] - 2026-04-01¶
Changed¶
- Version bump for PyPI release
[0.1.49] - 2026-03-30¶
Changed¶
- Version bump for PyPI release
[0.1.48] - 2026-03-29¶
Changed¶
- Version bump for PyPI release
[0.1.47] - 2026-03-28¶
Changed¶
- Version bump for PyPI release
[0.1.46] - 2026-03-28¶
Changed¶
- Version bump for PyPI release
[0.1.45] - 2026-03-27¶
Added¶
CCP Guarantor Risk Weight Support (CRR Art. 306 / CRE54.14-15)¶
CCP guarantors now receive the prescribed QCCP risk weight (2% proprietary / 4% client-cleared) instead of being treated as generic unrated institutions (40% RW). The guarantee substitution when/then chain in both the SA calculator and IRB namespace checks guarantor_entity_type == "ccp" before the institution/MDB branch, applying QCCP_PROPRIETARY_RW (2%) or QCCP_CLIENT_CLEARED_RW (4%) based on guarantor_is_ccp_client_cleared.
- CRM processor and namespace propagate
is_ccp_client_clearedfrom guarantor counterparty data - Entity type normalization (
.str.to_lowercase()) applied to guarantor entity type joins
[0.1.44] - 2026-03-25¶
Added¶
~~Article 114(4)~~ Article 114(7) EU domestic currency 0% risk weight for EU sovereigns¶
Correction (D4.35)
The original entry cited Art. 114(4). In the UK-onshored CRR, Art. 114(4) covers only the UK central government and Bank of England in sterling. EU member state domestic-currency treatment is provided by Art. 114(7) (third-country reciprocity).
EU member state central government and central bank exposures denominated in that member state's domestic currency now receive 0% risk weight regardless of CQS, per CRR Art. 114(7). Covers all 27 EU member states: eurozone members (EUR) and non-euro members in their national currencies (PLN, SEK, CZK, DKK, HUF, BGN, RON). EU domestic sovereign exposures are also forced to the Standardised Approach, preventing internal models from overriding the regulatory 0% treatment. Applies to both direct exposures and guarantor risk weight substitution (SA and IRB).
is_ccp_client_clearedfield added to data generators
Fixed¶
- CCP exposures now forced to SA approach with correct risk weights (was falling through to generic corporate treatment)
[0.1.43] - 2026-03-24¶
Fixed¶
Guarantee application expanded to facility and counterparty levels¶
Guarantee application previously only matched at direct (loan/exposure/contingent) level. Guarantees linked at facility or counterparty level were silently ignored. Now supports multi-level beneficiary matching: direct, facility (pro-rata across facility's exposures), and counterparty (pro-rata across all counterparty exposures).
[0.1.42] - 2026-03-22¶
Fixed¶
Slotting maturity not derived from maturity_date¶
The is_short_maturity flag for CRR Art. 153(5) specialised lending was never calculated from exposure maturity_date. It defaulted to False, causing all exposures to receive the >= 2.5yr risk weights regardless of actual remaining maturity. Strong category exposures with <2.5yr maturity now correctly receive 50% RW (was 70%), Good receives 70% (was 90%), HVCRE Strong receives 70% (was 95%), and HVCRE Good receives 95% (was 120%).
prepare_columns()now acceptsCalculationConfigand derivesis_short_maturityfrommaturity_dateandreporting_date- Extracted
exact_fractional_years_exprto sharedengine/utils.py(reused by IRB and slotting) - Added
remaining_maturity_yearscolumn to slotting audit trail - Added CRR-E5 through CRR-E8 acceptance scenarios for short-maturity slotting
UK govt guarantee exposure marked "not beneficial" for non-sovereign entity types¶
Guarantor risk weight lookup used regex matching on guarantor_entity_type (e.g., contains("SOVEREIGN")), which only matched sovereign but not central_bank, bank, company, or mdb. These entity types produced null guarantor RW, causing beneficial guarantees to be incorrectly skipped. The lookup now uses guarantor_exposure_class (derived from the existing ENTITY_TYPE_TO_SA_CLASS mapping), ensuring all valid entity types resolve to the correct SA risk weight. Also adds Art. 114(4) domestic sovereign treatment: UK CGCB guarantors in GBP receive 0% RW regardless of CQS. (Correction (D4.35): original entry cited Art. 114(3); Art. 114(3) is the ECB provision, Art. 114(4) is UK domestic currency.) Both SA calculator and IRB namespace are fixed. CRM processor and namespace now propagate guarantor_country_code from counterparty data.
[0.1.41] - 2026-03-22¶
Added¶
~~Article 114(3)~~ Article 114(4) domestic currency 0% risk weight for UK sovereign¶
Correction (D4.35)
The original entry cited Art. 114(3). CRR Art. 114(3) is the ECB 0% provision. The UK domestic currency provision is Art. 114(4).
UK central government and central bank exposures denominated in GBP now receive 0% risk weight regardless of CQS, per CRR Art. 114(4). Previously, 0% was only assigned via CQS 1 external rating lookup. The override applies in both CRR and Basel 3.1 SA risk weight chains. Foreign-currency UK sovereign exposures continue to use the standard CQS-based risk weight table.
[0.1.40] - 2026-03-22¶
Changed¶
Specialised lending now input-driven via counterparty_reference¶
Specialised lending metadata (sl_type, slotting_category, is_hvcre) is now supplied as an input file (exposures/specialised_lending.parquet) keyed by counterparty_reference, rather than being derived from counterparty reference naming conventions. This allows a corporate counterparty to have both SL and non-SL exposures, aligning with CRR Art. 147(8) and BCBS CRE30.6.
- New input file:
ratings/specialised_lending.parquet - Schema change:
exposure_referencereplaced withcounterparty_reference;remaining_maturity_yearsremoved (sourced from loan/facility data) - Removed dead code:
_build_slotting_category_expr(),_build_sl_type_expr(), and counterparty reference naming convention logic in the classifier
Fixed¶
FI scalar (apply_fi_scalar) not applied to IRB correlation¶
The apply_fi_scalar counterparty flag was gated on is_financial_sector_entity, which required the entity_type to be an institution-like value. Counterparties with entity_type="corporate" and apply_fi_scalar=True silently received no 1.25x correlation multiplier. The classifier now derives requires_fi_scalar directly from the user-supplied apply_fi_scalar flag.
Removed dead code: FINANCIAL_SECTOR_ENTITY_TYPES, is_financial_sector_entity, and is_large_financial_sector_entity — set in the classifier but never consumed by any calculation engine.
[0.1.39] - 2026-03-21¶
Fixed¶
- SME managed-as-retail 75% RW now correctly gated on EUR 1m turnover threshold check (was applying 75% RW without verifying threshold)
Changed¶
- Documentation aligned with current codebase state
[0.1.38] - 2026-03-20¶
Fixed¶
- Null
slotting_categoryandsl_typefor non-slotting exposures (was leaving stale values from classification) - Defaulted exposure treatment for SA risk weights now correctly implemented
- Case-insensitive column value validation (lowercase valid values set before comparison)
country_codesandexcluded_book_codescolumns inmodel_permissionsinput are now truly optional — when absent, treated as null (all geographies permitted, no book code exclusions). Previously causedColumnNotFoundError- Documentation aligned with code schemas across 13 files
[0.1.37] - 2026-03-17¶
Fixed¶
- Validation error messages now correctly convert file paths to string (was raising
TypeErrorforPathobjects)
[0.1.36] - 2026-03-15¶
Changed¶
Model ID moved from counterparty to ratings level (Breaking)¶
model_id has been moved from COUNTERPARTY_SCHEMA to RATINGS_SCHEMA. The rating inheritance pipeline now carries model_id alongside internal_pd through parent-child inheritance, eliminating the redundant counterparty-to-exposure propagation path.
- Removed:
model_idfromCOUNTERPARTY_SCHEMA - Added:
model_idtoRATINGS_SCHEMA - Updated: Rating inheritance pipeline carries
internal_model_idthrough coalesce (own → parent) - Updated:
_unify_exposures()sourcesmodel_idfrom rating inheritance instead of counterparty join - Updated: Fixture generators, integration tests, benchmark data generators, and documentation
- Counterparty data handling consolidated
[0.1.35] - 2026-03-11¶
Added¶
Integration Test Infrastructure¶
Comprehensive integration test suite covering the full pipeline from loader to output:
- Phase 1: Hierarchy → Classifier flow tests
- Phase 2: Classifier → CRM and CRM → Calculators flow tests
- Phase 3: Loader → Hierarchy, model permissions, and output floor tests
- Phase 4: Equity flow integration tests
- Integration test strategy document and shared infrastructure
Changed¶
model_idadded to counterparty-level schema (subsequently moved to ratings in 0.1.36)
[0.1.34] - 2026-03-10¶
Added¶
Model-Level IRB Permissions¶
Per-model IRB approach gating replaces the org-wide IRBPermissions config when a model_permissions input file is provided:
- New schema:
MODEL_PERMISSIONS_SCHEMAwithmodel_id,exposure_class,approach,country_codes,excluded_book_codes - New column:
model_idonFACILITY_SCHEMA,LOAN_SCHEMA,CONTINGENTS_SCHEMA— links exposures to their IRB model - Classifier:
_resolve_model_permissions()joins exposures with model permissions, filters by geography and book code, gates approach on both permission and data availability (AIRB requiresinternal_pd+lgd; FIRB requires onlyinternal_pd) - Backward compatible: When no
model_permissionsfile is present, org-wideIRBPermissionsfallback applies - Validation:
model_permissionsincluded invalidate_raw_data_bundle()andvalidate_bundle_values()for schema and value validation model_permissionsfixtures andmodel_idadded to exposure generators- API documentation updated
- 10 unit tests covering AIRB/FIRB gating, geography filters, book code exclusions, and backward compatibility
Rename is_regulated → apply_fi_scalar¶
Simplified FI scalar control on COUNTERPARTY_SCHEMA:
- Schema:
is_regulatedrenamed toapply_fi_scalar— direct user-controlled flag replacing the intermediate boolean - Classifier:
requires_fi_scalarnow derives fromis_financial_sector_entity AND cp_apply_fi_scalar(simpler than the previous two-condition inference fromis_regulated) - Documentation: All references updated across input schemas, architecture, and classification docs
[0.1.33] - 2026-03-09¶
Added¶
Dual Per-Type Rating Resolution¶
Rating inheritance now resolves best internal and best external rating per counterparty independently. CQS is an external-only concept; internal ratings carry PD values without internal CQS.
- Per-type columns:
internal_pd,internal_rating_value,external_cqs,external_rating_value - Per-type inheritance: own internal → parent internal, own external → parent external (independent chains)
- Removed internal CQS references throughout the codebase
Changed¶
- Enhanced netting facility handling in loan data
[0.1.32] - 2026-03-08¶
Added¶
netting_facility_referencefield added toLOAN_SCHEMAand loan data for explicit netting group assignment
[0.1.31] - 2026-03-07¶
Added¶
- Enhanced netting logic for facility siblings (pro-rata allocation within netting groups)
interest_for_eadfunction in CCF module to handle negative interest values
[0.1.30] - 2026-03-06¶
Added¶
Basel 3.1 Engine¶
Full Basel 3.1 framework implementation alongside existing CRR support:
- Revised SA risk weight tables (CRE20.7-26) with LTV-band risk weights for residential and commercial real estate
- Basel 3.1 supervisory haircuts and F-IRB LGD framework dispatch
- Output floor: SA-equivalent RWA calculation on all IRB rows with phase-in schedule
- Basel 3.1 acceptance tests: B31-B (F-IRB), B31-C (A-IRB), B31-D (CRM), B31-E (slotting), B31-G (provisions), B31-H (complex scenarios) — 116 tests total
- IRB: A-IRB LGD floors gated on
is_airbcolumn (CRE30.41) - IRB: QRRE transactor/revolver PD floor distinction (CRR Art. 147(5), CRE30.55)
Dual-Framework Comparison and Analysis¶
- M3.1: CRR vs Basel 3.1 side-by-side comparison with per-exposure RWA delta
- M3.2: Capital impact analysis with driver attribution
- M3.3: Transitional floor schedule modelling with year-by-year phase-in
- M3.4: Enhanced Marimo workbook for interactive impact analysis
EL Shortfall/Excess (CRR Art. 158-159)¶
Expected loss shortfall/excess computation for IRB portfolios, with portfolio-level Tier 2 credit cap per CRR Art. 62(d).
COREP Template Generation (FR-4.6 / M4.1)¶
Regulatory reporting templates for CRR firms following EBA/PRA COREP structure (Regulation (EU) 2021/451):
- C 07.00 — SA credit risk: original exposure, SA EAD, RWA by exposure class, plus risk weight band breakdown
- C 08.01 — IRB totals: original exposure, IRB EAD, RWA, expected loss, weighted-average PD/LGD/maturity by exposure class
- C 08.02 — IRB PD grade breakdown: obligor-grade-level detail with standard PD bands and exposure-weighted averages
COREPGeneratorclass withgenerate()andexport_to_excel()methodsResultExporter.export_to_corep()for multi-sheet Excel exportCalculationResponse.to_corep()convenience method
Programmatic Export API (FR-4.7)¶
Export calculation results to Parquet, CSV, and Excel formats programmatically.
On-Balance Sheet Netting (CRR Article 195)¶
Support for on-balance sheet netting of mutual claims when a legally enforceable netting agreement exists:
- New fields:
has_netting_agreementandnetting_facility_referenceonLOAN_SCHEMAandLoanfixture - Synthetic cash collateral: Negative-drawn netting-eligible loans generate cash collateral that reduces all positive-drawn sibling exposures pro-rata within the same netting facility
- Netting facility resolution: Priority chain — explicit
netting_facility_reference→root_facility_reference→parent_facility_reference - SA: EAD reduced by netting pool (cash = 0% haircut)
- F-IRB: LGD reduced via cash collateral path (0% LGD)
- FX mismatch: 8% haircut applied when currencies differ
Service API Documentation¶
Restructured user-facing documentation to promote the high-level Service API (quick_calculate, RWAService) as the primary entry point:
- Quick Start rewritten with 3-tier progression:
quick_calculateone-liner,RWAServicewith more control, full example with validation/export - New page:
docs/api/service.md— complete Service API reference - API Reference index features Service API as first module
Basel 3.1 Parameter Substitution for IRB Guarantors (CRE22.70-85)¶
IRB guarantee substitution parameters updated for Basel 3.1 framework.
CI/CD Pipeline¶
GitHub Actions workflow with lint, typecheck, and test jobs.
Changed¶
- Replaced
EnumwithStrEnumandIntEnumthroughout the codebase - Centralised data source configuration with
DataSourceRegistryreplacingRequiredFiles - Introduced
BaseRequestclass to reduce duplication in request models - Error factory functions updated to support
Pathtypes alongsidestr - Tests migrated to use
Pathfor file paths
Fixed¶
- Corporate bond haircut CQS grouping corrected per CRR Art. 224
- PD floors and transitional schedule corrected to PRA PS1/26
- Output floor
sa_rwacomputation fixed for acceptance tests - Benchmark data generators now include all schema columns (
is_buy_to_let,interest,bs_type,pledge_percentage,is_qrre_transactor) - Benchmark tests updated for current API:
_unify_exposuressignature (addedfacilitiesarg),CRMProcessor.get_crm_adjusted_bundle - Protocol test stubs updated to include
calculate_branchmethod
[0.1.29] - 2026-02-28¶
Added¶
- F-IRB acceptance tests and expected outputs (CRR-B1 through B7)
Changed¶
- Pipeline refactored to single-pass calculation for unified frame (filter-process-merge pattern)
- Classifier exposure classification logic optimized
- Hierarchy collateral allocation logic simplified
- RWA calculations simplified with filter-process-merge approach
Performance¶
- Pipeline optimizations: pre-computed classifier intermediates, deferred audit string, slimmed counterparty join, eliminated unnecessary
collect_schema()calls - Full CRR pipeline at 100K: ~1.7s mean (SA-only ~1.7s, CRR ~1.9s)
[0.1.28] - 2026-02-24¶
Added¶
- Benchmarking module for RWA Calculator performance testing
Performance¶
- Optimized aggregation data collection and processing
- Optimized hierarchy graph traversal methods
- Optimized exposure enrichment methods
- Optimized pledge resolution and validation in pipeline
[0.1.27] - 2026-02-22¶
Added¶
- Results caching with lazy loading for improved pipeline performance
Changed¶
- Replaced custom validation methods with shared utility functions across hierarchy, loader, pipeline, and processor
- Replaced
enable_irbboolean config withirb_approachenum for clearer IRB permission modelling - Optimized data materialization to reduce redundant
.collect()calls - Multiple speed optimization PRs merged (aggregator, formatters, validation)
[0.1.26] - 2026-02-21¶
Performance¶
- Optimized aggregator processing for large result sets
- Optimized formatter output generation
- Streamlined validation data processing to reduce overhead
- UI speed improvements for interactive calculator
[0.1.25] - 2026-02-20¶
Added¶
IRB Defaulted Exposure Treatment (CRR Art. 153(1)(ii), 154(1)(i))¶
- Defaulted exposures (PD=1.0) receive K=0 under F-IRB and K=max(0, LGD-BEEL) under A-IRB
- Expected loss = LGD × EAD for defaulted exposures
- CRR 1.06 scaling factor correctly applied to defaulted corporate exposures
- New CRR-I acceptance test group with 9 tests (I1 F-IRB corporate, I2 A-IRB retail, I3 A-IRB corporate with CRR scaling)
Fixed¶
- SME supporting factor now correctly uses drawn amount (not EAD) for tier threshold calculation
[0.1.24] - 2026-02-19¶
Added¶
Multi-Level SA Collateral Allocation¶
- Multi-level collateral allocation for SA EAD reduction with overcollateralisation compliance
- Haircut calculator enhancements for multi-level processing
[0.1.23] - 2026-02-17¶
Added¶
SA Provision Handling — Art. 111(1)(a)-(b) Compliance¶
Provisions are now resolved before CCF application using a drawn-first deduction approach, compliant with CRR Art. 111(1)(a)-(b):
Pipeline reorder:
New method: resolve_provisions() with multi-level beneficiary resolution:
- Direct (loan/exposure/contingent): provision matched to specific exposure
- Facility: distributed pro-rata across facility's exposures
- Counterparty: distributed pro-rata across all counterparty exposures
SA drawn-first deduction:
- provision_on_drawn = min(provision, max(0, drawn)) — absorbs provision against drawn first
- Remainder → provision_on_nominal — reduces nominal before CCF
- nominal_after_provision = nominal_amount - provision_on_nominal feeds into CCF
IRB/Slotting: Provisions tracked (provision_allocated) but NOT deducted from EAD (feeds EL shortfall/excess comparison)
New columns:
| Column | Type | Description |
|--------|------|-------------|
| provision_on_drawn | Float64 | Provision absorbed by drawn (SA only) |
| provision_on_nominal | Float64 | Provision reducing nominal before CCF (SA only) |
| nominal_after_provision | Float64 | nominal_amount - provision_on_nominal |
| provision_deducted | Float64 | Total = provision_on_drawn + provision_on_nominal |
| provision_allocated | Float64 | Total provision matched to this exposure |
Other changes:
- finalize_ead() no longer subtracts provisions (already baked into ead_pre_crm)
- _initialize_ead() preserves existing provision columns if set by resolve_provisions
- 14 unit tests in tests/unit/crm/test_provisions.py
- CCF test suite expanded to 57 tests
[0.1.22] - 2026-02-16¶
Changed¶
- Slotting risk weights updated for remaining maturity splits (CRR Art. 153(5))
- Config enhancements for slotting maturity bands
[0.1.21] - 2026-02-16¶
Added¶
Pledge Percentage for Collateral Valuation¶
- Introduced
pledge_percentagefield to allow collateral to be specified as a percentage of the beneficiary's EAD - Collateral processing resolves
pledge_percentageto absolute market values based on beneficiary type (loan, facility, or counterparty level) - Updated input schemas and CRM methodology documentation to reflect the new field
- 403 lines of new tests covering pledge percentage resolution across different beneficiary levels
[0.1.20] - 2026-02-14¶
Added¶
Equity Exposure FX Conversion¶
- New
convert_equity_exposures()method in FX converter for converting equity exposure values to reporting currency - Updated classifier and hierarchy to support equity exposures in FX conversion pipeline
- Enhanced FX rate configuration with equity-specific handling
- Comprehensive tests for equity exposure conversion and currency handling
[0.1.19] - 2026-02-11¶
Added¶
Buy-to-Let Flag¶
- New
is_buy_to_letboolean flag in hierarchy and schemas for identifying BTL exposures - BTL exposures excluded from SME supporting factor discount
- Unit tests verifying BTL flag behaviour in supporting factor calculations
On-Balance EAD Helper¶
- New
on_balance_ead()helper function in CCF module calculating EAD asmax(0, drawn) + interest - Updated CRM processor and namespace to use the new helper
- Comprehensive tests covering various on-balance EAD scenarios
Changed¶
- Updated implementation plan and roadmap documentation with current test results and fixture completion status
[0.1.18] - 2026-02-10¶
Added¶
Facility Hierarchy Enhancements¶
- Facility root lookup and undrawn calculations for full facility hierarchy resolution
- Include contingent liabilities in facility undrawn calculations
- Enhanced facility hierarchy resolution logic
[0.1.17] - 2026-02-10¶
Added¶
- CCF: handle negative drawn amounts in EAD calculations
Fixed¶
- Hierarchy: resolve duplicate mapping issues in facility calculations
[0.1.16] - 2026-02-09¶
Added¶
Cross-Approach CCF Substitution¶
- SA CCF expression and cross-approach substitution for guaranteed IRB exposures
- When an IRB exposure is guaranteed by an SA counterparty, the guaranteed portion uses SA CCFs
- New columns:
ccf_original,ccf_guaranteed,ccf_unguaranteed,guarantee_ratio,guarantor_approach,guarantor_rating_type
Aggregator Enhancements¶
- Updated summaries for post-CRM reporting
- Enhanced approach handling for IRB results
[0.1.15] - 2026-02-08¶
Added¶
- Correlation: rename sovereign exposure class to central govt/central bank
- CI: add GitHub Actions workflow for documentation deployment
[0.1.14] - 2026-02-07¶
Added¶
Overcollateralisation Requirements (CRR Art. 230 / CRE32.9-12)¶
Non-financial collateral now requires overcollateralisation to receive CRM benefit:
| Collateral Type | Overcollateralisation Ratio | Minimum Threshold |
|---|---|---|
| Financial | 1.0x | No minimum |
| Receivables | 1.25x | No minimum |
| Real estate | 1.4x | 30% of EAD |
| Other physical | 1.4x | 30% of EAD |
effectively_secured = adjusted_value / overcollateralisation_ratio- Financial vs non-financial collateral tracked separately for threshold checks
- Multi-level allocation respects overcollateralisation at each level
Changed¶
- Standardized
collateral_typecasing and descriptions across codebase
[0.1.13] - 2026-02-07¶
Added¶
Input Value Validation¶
validate_bundle_values()validates all categorical columns againstCOLUMN_VALUE_CONSTRAINTS- Error code
DQ006for invalid column values - Pipeline calls
_validate_input_data()as non-blocking step (errors collected, not raised)
Fixed¶
- Prevented row duplication in exposure joins when
facility_reference = loan_reference(#71)
[0.1.12] - 2026-02-02¶
Added¶
Equity Exposure Calculator¶
Complete equity exposure RWA calculation supporting two regulatory approaches:
Article 133 - Standardised Approach (SA): | Equity Type | Risk Weight | |-------------|-------------| | Central bank | 0% | | Listed/Exchange-traded/Government-supported | 100% | | Unlisted/Private equity | 250% | | Speculative | 400% |
Article 155 - IRB Simple Risk Weight Method: | Equity Type | Risk Weight | |-------------|-------------| | Central bank | 0% | | Private equity (diversified portfolio) | 190% | | ~~Government-supported~~ | ~~190%~~ | | Exchange-traded/Listed | 290% | | Other equity | 370% |
Correction (D1.27)
"Government-supported: 190%" was incorrectly listed as an Art. 155 category. Art. 155(2) has only three categories: (a) exchange-traded 290%, (b) PE diversified 190%, (c) all other 370%. No "government-supported" category exists in Art. 155.
New Components:
- EquityCalculator class (src/rwa_calc/engine/equity/calculator.py)
- EquityLazyFrame namespace (lf.equity) for fluent calculations
- EquityExpr namespace (expr.equity) for column-level operations
- EquityResultBundle for equity calculation results
- crr_equity_rw.py lookup tables
Features: - Automatic approach determination based on IRB permissions - Diversified portfolio treatment for private equity (190% vs 370%) - Full audit trail generation - Single exposure calculation convenience method
Pre/Post CRM Tracking for Guarantees¶
Enhanced guarantee processing with full tracking of exposure amounts before and after CRM application:
- rwa_pre_crm: RWA calculated on original exposure before guarantee
- rwa_post_crm: RWA calculated after guarantee substitution
- guarantee_rwa_benefit: Reduction in RWA from guarantee protection
- Supports both covered and uncovered portion tracking
Changed¶
- Pipeline now includes equity calculator between CRM and aggregator
CRMAdjustedBundleextended withequity_exposuresfield
[0.1.11] - 2026-01-28¶
Added¶
- Namespace: add exact fractional years calculation
- Config: add MCP server configuration
[0.1.10] - 2026-01-28¶
Added¶
- CCF: include interest in EAD calculations
[0.1.8] - 2026-01-28¶
Added¶
- Data: add script to generate sample data in parquet format
- Correlation: add SME adjustment with EUR/GBP conversion
- Orgs: make org_mappings optional in data loaders
Fixed¶
- Config: update EUR to GBP exchange rate
[0.1.7] - 2026-01-27¶
Added¶
- Tests: add unit tests for API error handling and validation
- Protocols: update aggregation method with new bundles
- Loader: enhance data loading with validation checks
- BDD: add specifications for CRR provisions, risk weights, and supporting factors
Changed¶
- Loans: update loan schema and documentation
[0.1.6] - 2026-01-25¶
Added¶
- Stats: implement backend detection for statistical functions
- Documentation: add detailed implementation plan and project roadmap
Changed¶
- Stats: remove dual stats backend implementation
- Documentation: update optional dependencies and installation instructions
[0.1.5] - 2026-01-25¶
Added¶
- Counterparties: enhance counterparty schema and classification
- Documentation: add logo to documentation theme
Changed¶
- CCF: remove unused CCF module and tests
- Contingents: remove ccf_category and update risk_type
Performance¶
- Benchmark: update results with improved metrics
[0.1.4] - 2026-01-25¶
Added¶
- Deploy: add automated deployment script
Performance¶
- Benchmark: transition to pure Polars expressions
[0.1.3] - 2025-01-24¶
Added¶
Documentation Code Linking¶
- Updated documentation to link code examples to actual source implementations
- Added
pymdownx.snippetsfor embedding real code from source files - Added
mkdocstringsauto-generated API documentation - New
docs/development/documentation-conventions.mdguide for contributors - Source code references with GitHub line number links throughout docs
Mandatory risk_type Column for CCF Determination¶
The risk_type column is now the authoritative source for CCF (Credit Conversion Factor) determination across all facility inputs:
New Columns:
- risk_type (mandatory) - Off-balance sheet risk category: FR, MR, MLR, LR
- ccf_modelled (optional) - A-IRB modelled CCF estimate (0.0-1.5, Retail IRB can exceed 100%)
- is_short_term_trade_lc (optional) - CRR Art. 166(9) exception flag
Risk Type Values (CRR Art. 111):
| Code | SA CCF | F-IRB CCF | Description |
|---|---|---|---|
| FR | 100% | 100% | Full risk - guarantees, credit substitutes |
| MR | 50% | 75% | Medium risk - NIFs, RUFs, committed undrawn |
| MLR | 20% | 75% | Medium-low risk - documentary credits, trade |
| LR | 0% | 0% | Low risk - unconditionally cancellable |
F-IRB Rules:
- CRR Art. 166(8): MR and MLR both become 75% CCF under F-IRB
- CRR Art. 166(9): Short-term trade LCs for goods movement retain 20% (set is_short_term_trade_lc=True)
A-IRB Support:
- When ccf_modelled is provided and approach is A-IRB, this value takes precedence
Removed¶
commitment_type Column and Legacy CCF Functions¶
The following have been removed as risk_type is now the authoritative CCF source:
Removed from schemas:
- commitment_type column from FACILITY_SCHEMA and all intermediate schemas
Removed from crr_ccf.py:
- lookup_ccf() function
- lookup_firb_ccf() function
- calculate_ead_off_balance_sheet() function
- create_ccf_type_mapping_df() function
Removed from ccf.py:
- calculate_single_ccf() method
- CCFResult dataclass
Migration: Replace commitment_type with risk_type:
- unconditionally_cancellable → LR (low_risk)
- committed_other → MR (medium_risk) or MLR (medium_low_risk)
FX Conversion Support (14 new tests)¶
Multi-currency portfolio support with configurable FX conversion:
FXConverter Module (src/rwa_calc/engine/fx_converter.py)
- convert_exposures() - Converts drawn, undrawn, and nominal amounts
- convert_collateral() - Converts market and nominal values
- convert_guarantees() - Converts covered amounts
- convert_provisions() - Converts provision amounts
- Factory function create_fx_converter()
Features:
- Configurable target currency via CalculationConfig.base_currency
- Enable/disable via CalculationConfig.apply_fx_conversion
- Full audit trail: original_currency, original_amount, fx_rate_applied
- Graceful handling of missing FX rates (values unchanged, rate = null)
- Early pipeline integration (HierarchyResolver) for consistent threshold calculations
Data Support:
- New FX_RATES_SCHEMA in src/rwa_calc/data/schemas.py
- fx_rates field added to RawDataBundle
- fx_rates_file config in DataSourceConfig
- Test fixtures in tests/fixtures/fx_rates/
Tests: - 14 unit tests covering all conversion scenarios - Tests for exposure, collateral, guarantee, and provision conversion - Multi-currency batch conversion tests - Alternative base currency tests (EUR, USD)
Polars Namespace Extensions (8 namespaces, 139 new tests)¶
The calculator now provides comprehensive Polars namespace extensions for fluent, chainable calculations across all approaches:
SA Namespace (lf.sa, expr.sa)
- SALazyFrame namespace for Standardised Approach calculations
- Methods: prepare_columns, apply_risk_weights, apply_residential_mortgage_rw, apply_cqs_based_rw, calculate_rwa, apply_supporting_factors, apply_all
- UK deviation handling for institution CQS 2 (30% vs 50%)
- 29 unit tests
IRB Namespace (lf.irb, expr.irb)
- IRBLazyFrame namespace for IRB calculations
- Methods: classify_approach, apply_firb_lgd, prepare_columns, apply_pd_floor, apply_lgd_floor, calculate_correlation, calculate_k, calculate_maturity_adjustment, calculate_rwa, calculate_expected_loss, apply_all_formulas
- Expression methods: floor_pd, floor_lgd, clip_maturity
- 33 unit tests
CRM Namespace (lf.crm)
- CRMLazyFrame namespace for EAD waterfall processing
- Methods: initialize_ead_waterfall, apply_collateral, apply_guarantees, apply_provisions, finalize_ead, apply_all_crm
- SA vs IRB treatment differences handled automatically
- 20 unit tests
Haircuts Namespace (lf.haircuts)
- HaircutsLazyFrame namespace for collateral haircut calculations
- Methods: classify_maturity_band, apply_collateral_haircuts, apply_fx_haircut, apply_maturity_mismatch, calculate_adjusted_value, apply_all_haircuts
- CRR Article 224 supervisory haircuts
- 24 unit tests
Slotting Namespace (lf.slotting, expr.slotting)
- SlottingLazyFrame namespace for specialised lending
- Methods: prepare_columns, apply_slotting_weights, calculate_rwa, apply_all
- CRR vs Basel 3.1 risk weight differences
- HVCRE treatment
- 26 unit tests
Hierarchy Namespace (lf.hierarchy)
- HierarchyLazyFrame namespace for hierarchy resolution
- Methods: resolve_ultimate_parent, calculate_hierarchy_depth, inherit_ratings, coalesce_ratings, calculate_lending_group_totals, add_lending_group_reference, add_collateral_ltv
- Pure LazyFrame join-based traversal (no Python recursion)
- 13 unit tests
Aggregator Namespace (lf.aggregator)
- AggregatorLazyFrame namespace for result combination
- Methods: combine_approach_results, apply_output_floor, calculate_floor_impact, generate_summary_by_class, generate_summary_by_approach, generate_supporting_factor_impact
- Basel 3.1 output floor support
- 12 unit tests
Audit Namespace (lf.audit, expr.audit)
- AuditLazyFrame namespace for audit trail generation
- Methods: build_sa_calculation, build_irb_calculation, build_slotting_calculation, build_crm_calculation, build_haircut_calculation, build_floor_calculation
- AuditExpr namespace for column formatting: format_currency, format_percent, format_ratio, format_bps
- 15 unit tests
Changed¶
- All calculators can now use namespace-based fluent APIs
- Improved code readability with chainable method calls
- Test count increased from 635 to 826 (139 namespace tests + 14 FX converter tests + 38 other tests)
[0.1.2] - 2025-01-24¶
Added¶
Interactive UI Console Command¶
- New
rwa-calc-uiconsole script for starting the UI server when installed from PyPI main()function added toserver.pyfor entry point
Documentation Improvements¶
- New
docs/user-guide/interactive-ui.md- comprehensive UI guide with prerequisites, all three apps, troubleshooting - Updated quickstart with "Choose Your Approach" section (UI vs Python API)
- Added Interactive UI to user guide navigation and recommendations
- Updated all server startup commands to show both PyPI and source installation methods
Changed¶
- Installation instructions clarified for PyPI vs source installations
- UI documentation moved from Development section to User Guide for better discoverability
[0.1.1] - 2025-01-22¶
Added¶
- FX conversion support for multi-currency portfolios
- Polars namespace extensions (8 namespaces)
- Retail classification flag (
cp_is_managed_as_retail)
[0.1.0] - 2025-01-18¶
Added¶
Core Framework¶
- Dual-framework support (CRR and Basel 3.1 configuration)
- Pipeline architecture with discrete processing stages
- Protocol-based component interfaces
- Immutable data contracts (bundles)
Data Loading¶
- Parquet file loader
- Schema validation
- Optional file handling
- Metadata tracking
Hierarchy Resolution¶
- Counterparty hierarchy resolution (up to 10 levels)
- Rating inheritance from parent
- Lending group aggregation
- LazyFrame-based join optimization
Classification¶
- All exposure classes supported
- Approach determination (SA/F-IRB/A-IRB/Slotting)
- SME identification
- Retail eligibility checking
- EAD calculation with CCFs
Standardised Approach¶
- Complete risk weight tables
- Sovereign, Institution, Corporate, Retail classes
- Real estate treatments
- Defaulted exposure handling
IRB Approach¶
- K formula implementation
- Asset correlation with SME adjustment
- Maturity adjustment
- PD and LGD floors
- Expected loss calculation
- 1.06 scaling factor (CRR)
Slotting Approach¶
- All specialised lending types
- Category-based risk weights
- HVCRE treatment
- Pre-operational project finance
Credit Risk Mitigation¶
- Financial collateral (comprehensive method)
- Supervisory haircuts
- Currency mismatch handling
- Guarantees (substitution approach)
- Maturity mismatch adjustment
- Provision allocation
Supporting Factors (CRR)¶
- SME supporting factor (tiered calculation)
- Infrastructure factor
Output¶
- Aggregated results
- Breakdown by approach/class/counterparty
- Export to Parquet/CSV/JSON
- Error accumulation and reporting
Configuration¶
- Factory methods (crr/basel_3_1)
- EUR/GBP rate configuration
- Configurable supporting factors
- PD floor configuration
Testing¶
- 468+ test cases
- Unit tests for all components
- Contract tests for interfaces
- Acceptance test framework
- Test fixtures generation
Documentation¶
- MkDocs with Material theme
- User guide for all audiences
- API reference
- Architecture documentation
- Development guide
Technical¶
- Python 3.13+ support
- Polars LazyFrame optimization
- Pydantic validation
- Type hints throughout
- Ruff formatting/linting
Version History¶
| Version | Date | Status |
|---|---|---|
| 0.2.5 | 2026-05-02 | Current |
| 0.2.4 | 2026-04-30 | Previous |
| 0.2.3 | 2026-04-28 | - |
| 0.2.2 | 2026-04-27 | - |
| 0.2.1 | 2026-04-27 | - |
| 0.2.0 | 2026-04-26 | - |
| 0.1.67 | 2026-04-25 | - |
| 0.1.66 | 2026-04-24 | - |
| 0.1.65 | 2026-04-21 | - |
| 0.1.64 | 2026-04-19 | - |
| 0.1.63 | 2026-04-19 | - |
| 0.1.62 | 2026-04-17 | - |
| 0.1.61 | 2026-04-15 | - |
| 0.1.60 | 2026-04-14 | - |
| 0.1.59 | 2026-04-14 | - |
| 0.1.58 | 2026-04-11 | - |
| 0.1.57 | 2026-04-11 | - |
| 0.1.56 | 2026-04-11 | - |
| 0.1.55 | 2026-04-09 | - |
| 0.1.54 | 2026-04-08 | - |
| 0.1.53 | 2026-04-07 | - |
| 0.1.52 | 2026-04-06 | - |
| 0.1.51 | 2026-04-05 | - |
| 0.1.50 | 2026-04-01 | - |
| 0.1.49 | 2026-03-30 | - |
| 0.1.48 | 2026-03-29 | - |
| 0.1.47 | 2026-03-28 | - |
| 0.1.46 | 2026-03-28 | - |
| 0.1.45 | 2026-03-27 | - |
| 0.1.44 | 2026-03-25 | - |
| 0.1.43 | 2026-03-24 | - |
| 0.1.42 | 2026-03-22 | - |
| 0.1.41 | 2026-03-22 | - |
| 0.1.40 | 2026-03-22 | - |
| 0.1.39 | 2026-03-21 | - |
| 0.1.38 | 2026-03-20 | - |
| 0.1.37 | 2026-03-17 | - |
| 0.1.36 | 2026-03-15 | - |
| 0.1.35 | 2026-03-11 | - |
| 0.1.34 | 2026-03-10 | - |
| 0.1.33 | 2026-03-09 | - |
| 0.1.32 | 2026-03-08 | - |
| 0.1.31 | 2026-03-07 | - |
| 0.1.30 | 2026-03-06 | - |
| 0.1.29 | 2026-02-28 | - |
| 0.1.28 | 2026-02-24 | - |
| 0.1.27 | 2026-02-22 | - |
| 0.1.26 | 2026-02-21 | - |
| 0.1.25 | 2026-02-20 | - |
| 0.1.24 | 2026-02-19 | - |
| 0.1.23 | 2026-02-17 | - |
| 0.1.22 | 2026-02-16 | - |
| 0.1.21 | 2026-02-16 | - |
| 0.1.20 | 2026-02-14 | - |
| 0.1.19 | 2026-02-11 | - |
| 0.1.18 | 2026-02-10 | - |
| 0.1.17 | 2026-02-10 | - |
| 0.1.16 | 2026-02-09 | - |
| 0.1.15 | 2026-02-08 | - |
| 0.1.14 | 2026-02-07 | - |
| 0.1.13 | 2026-02-07 | - |
| 0.1.12 | 2026-02-02 | - |
| 0.1.11 | 2026-01-28 | - |
| 0.1.10 | 2026-01-28 | - |
| 0.1.8 | 2026-01-28 | - |
| 0.1.7 | 2026-01-27 | - |
| 0.1.6 | 2026-01-25 | - |
| 0.1.5 | 2026-01-25 | - |
| 0.1.4 | 2026-01-25 | - |
| 0.1.3 | 2025-01-24 | - |
| 0.1.2 | 2025-01-24 | - |
| 0.1.1 | 2025-01-22 | - |
| 0.1.0 | 2025-01-18 | Initial |
Migration Notes¶
From Previous Versions¶
This is the initial release. No migration required.
CRR to Basel 3.1¶
When transitioning calculations from CRR to Basel 3.1:
-
Update configuration:
-
Review impacted exposures:
- SME exposures (factor removal)
- Infrastructure exposures (factor removal)
-
Low-risk IRB portfolios (output floor)
-
Update data requirements:
- LTV data for Basel 3.1 real estate weights
- Transactor/revolver flags for QRRE
Deprecation Notices¶
CRR-Specific Features (End of 2026)¶
The following CRR-specific features will be removed from active use after December 2026:
- SME supporting factor
- Infrastructure supporting factor
- 1.06 scaling factor
These will remain available for historical calculations and comparison.
Contributing¶
See Development Guide for contribution guidelines.
Support¶
For issues and feature requests, please use the project's issue tracker.