Parallel-Run Reconciliation¶
Run this calculator and your existing calculator on the same portfolio, then reconcile the two outputs component by component — exposure class, CCF, EAD, PD, LGD, risk weight, RWA, and more. Every exposure is bucketed as a match, a within-tolerance difference, a break, or missing on one side, with our engine's reason and input drivers attached so a break can be traced to a data issue or an engine difference.
This is the feature that lets a risk team gain comfort that the new calculator produces the right numbers before they switch.
Why reconcile before you switch¶
Most institutions adopting this calculator already run an incumbent credit-risk engine. The hardest part of a migration is not installing the new system — it is signing off that the new numbers are correct on your own book.
The acceptance suite gives statistical confidence on ~500 hand-derived regulatory scenarios. It cannot give absolute confidence on a real portfolio of millions of exposures with attribute combinations no test enumerates. As the project's own migration note puts it, production deployment requires "a parallel-run discipline against an existing system, signed-off reconciliation thresholds, and a rollback plan."
Parallel-run reconciliation operationalises exactly that discipline:
- Run both engines on the same portfolio for the same reporting date.
- Reconcile the outputs and review the headline tie-out and the break worklist.
- Triage each break: is our input different from theirs (a data-mapping fix), or do the inputs agree but the outputs diverge (an engine difference to investigate)?
- Sign off once breaks are explained and within agreed thresholds.
Not the same as CRR vs Basel 3.1 comparison
The Comparison & Impact Analysis feature runs the same engine across two internal frameworks (CRR and Basel 3.1) on the same inputs — it has no reconciliation issues because both sides come from one codebase. Parallel-run reconciliation is different: the other side is your external, legacy output, with its own column names, units, and identifiers that must be mapped onto our components.
How it works¶
flowchart LR
A["Your portfolio<br/>(input data)"] --> B["This calculator<br/>CreditRiskCalc.calculate()"]
A --> C["Your legacy<br/>calculator"]
C --> D["Legacy output file<br/>(CSV / Parquet)"]
B --> E["reconcile()"]
D -->|"TOML mapping"| E
E --> F["ReconciliationResponse<br/>(tie-out · buckets · breaks)"]
You supply our results (produced by the calculator) and your legacy output file, plus a small TOML mapping that says which legacy column is which component and how the two are keyed. The engine collapses our guarantee / real-estate sub-rows back to your reporting grain — and aggregates the legacy side the same way, so a legacy exposure split across several lines (a collateralised portion in one risk class, the residual in another) sums to the key grain instead of having all-but-the-first line dropped — joins the two sides, and buckets every mapped component.
What it compares¶
You map only the components you have. Each one is compared with a sensible default tolerance (overridable), and annotated with our engine's explain and input columns.
| Component | Kind | Compared as | Our "explain" (why) |
|---|---|---|---|
exposure_class |
categorical | exact label (after synonyms) | classification reason, pre-CRM class |
approach |
categorical | exact label | approach-selection reason, permission |
cqs |
numeric | exact (integer step) | rating source |
pd |
numeric | absolute tolerance | original PD, PD floor applied |
lgd |
numeric | absolute tolerance | original LGD, LGD floor, LGD type |
maturity |
numeric | absolute tolerance | residual maturity, maturity date |
ccf |
numeric | absolute tolerance | CCF regulatory source |
collateral |
numeric | relative tolerance | gross value, haircut, per-type split |
guarantee |
numeric | relative tolerance | method/status, guaranteed split |
ead |
numeric | relative tolerance | gross EAD, converted undrawn |
risk_weight |
numeric | absolute tolerance | RW regulatory reference / adjustment reason |
supporting_factor |
numeric | absolute tolerance | infra factor, SF benefit |
expected_loss |
numeric | relative tolerance | — |
rwa |
numeric | relative tolerance | — |
collateral, guarantee and cqs are additive/exact CRM drivers — map a legacy column for
each to compare collateral allocation, guarantee benefit and credit-quality step side-by-side.
A component you do not map simply shows our side only in the single-loan forensic.
Map approach to split the asset-class allocation by method
COREP reports each asset class per method (SA on C 07.00, IRB on C 08.0x). Mapping
[components.approach] gives the legacy side a method to split on, and the asset-class
allocation gains a per-methodology breakdown (STD / FIRB / AIRB / slotting / equity) —
one chart section and one table row-set per method. Without it the allocation stays
combined. Your value_map must land on our approach labels (standardised,
foundation_irb, advanced_irb, …), or the two sides split on keys that can never meet;
a REC007 warning names any value that failed to resolve.
[components.approach]
legacy_column = "Method"
value_map = { STD = "standardised", "IRB-F" = "foundation_irb", "IRB-A" = "advanced_irb" }
Both dimensions are post-guarantee: the guaranteed slice of an SA-guaranteed exposure is counted under the guarantor's class and its standardised method, so a class that ties out in total but is booked under a different approach on each side surfaces as offsetting deltas instead of a clean match.
Buckets¶
Every component on every row lands in one bucket, and the row takes its worst:
| Bucket | Meaning |
|---|---|
exact_match |
Identical (within floating-point noise). |
within_tolerance |
Differs, but within the configured tolerance. |
break |
Outside tolerance — needs investigation. |
missing_left |
In the legacy file but not in our results. |
missing_right |
In our results but not in the legacy file. |
Focusing on material differences¶
Our engine emits legitimate our-only lines that carry zero exposure — a fully-provisioned
exposure, a zero-undrawn facility row, a guarantee-remainder sub-row. Each is a correct
missing_right, but with zero gross exposure it adds nothing to any money total while
inflating the bucket counts, burying the real omissions. The overview and the per-key explorer
therefore offer an opt-in "Hide zero-gross-exposure rows" toggle (off by default). A row is
immaterial when its gross exposure — max(|our EAD|, |legacy EAD|), falling back to RWA when
EAD is unmapped — rounds to zero. Toggling it on re-derives every breakdown count (per bucket,
per component, per class / approach) and the biggest-breaks worklist with those rows removed;
the money tie-out and Σ|Δ| charts are unchanged (a zero-gross row contributes nothing). The
wide per-key frame carries gross_exposure and is_immaterial, so the same filter is
available in the explorer and the CSV / Excel export.
Data fix or engine fix?¶
The point of attaching our explain and input columns to every row is to make a break
triage-able. For a break on, say, risk_weight:
- If our input drivers (CQS, LTV band, …) differ from what the legacy system used → the break is a data / mapping issue — fix the feed or the mapping.
- If the inputs agree but the risk weight differs → it is an engine difference — raise it for investigation against the regulation.
Reading the report¶
The result is layered from a one-number verdict down to a single exposure's drivers.
Does it tie out? totals_tie_out gives sum-legacy vs sum-ours (and % delta) per
additive component; summary_by_component gives the per-component bucket counts and
break rate — i.e. which components agree and which are problematic.
Where do breaks concentrate, and is the allocation right? class_allocation totals
EAD/RWA by risk class on each side (a split exposure's portions each counted in their
own class) and shows the delta — so a class our engine allocates differently to the legacy
one stands out as offsetting deltas. Map [components.approach] and
class_allocation_by_method splits that same money by methodology within each class
(STD / FIRB / AIRB / slotting / equity), the way COREP reports each class per method — so a
class that ties out in total but is booked under a different approach on each side stops
hiding. summary_by_bucket, summary_by_exposure_class, and summary_by_approach show
whether breaks cluster in a particular bucket, class, or approach.
What to investigate first. breaks_detail is a long-format list of every
(exposure, component) break — legacy value, our value, absolute and relative delta, and
our explanation — ranked by materiality.
One exposure, end to end. The single-loan view reads as an ordered RWA-driver
chain — exposure class → approach → CQS → PD → LGD → maturity → CCF → collateral →
guarantee → EAD → risk weight → RWA — showing legacy vs ours (and Δ / status) at each
mapped step, with that step's explain/input columns nested beneath it as our-side drivers.
A driver with no legacy column in the mapping is marked "legacy not provided" so a
one-sided field is never read as agreement. This is the view you use to decide data fix
vs engine fix. (The underlying component_reconciliation frame — every column, all keys —
remains the CSV/Excel export's escape hatch.)
How to run it¶
1. Write a mapping (TOML)¶
The mapping declares the legacy file, the join key(s), and which legacy column feeds each
component. Numeric components accept scale (e.g. legacy figures in millions) and
unit = "percent"; categoricals accept a value_map of label synonyms; any component can
override its tolerance.
# reconciliation.toml
legacy_file = "./legacy_q4.csv"
legacy_format = "csv" # or "parquet"
legacy_keys = ["obligor_id", "facility_id"] # composite key supported
our_keys = ["counterparty_reference", "root_facility_reference"]
top_n = 50
[components.rwa]
legacy_column = "RWA_Amt"
scale = 1_000_000 # legacy RWA is in millions
tol_kind = "rel" # optional override
tol = 0.005 # 0.5% relative
[components.ead]
legacy_column = "EAD_Amt"
scale = 1_000_000
[components.risk_weight]
legacy_column = "RW_pct"
unit = "percent" # 20.0 -> 0.20
[components.exposure_class]
legacy_column = "Asset_Class"
value_map = { CORP = "corporate", RETAIL = "retail" }
The join key can be composite
legacy_keys and our_keys are positionally aligned, so you can reconcile at whatever
grain both systems share — a single exposure_reference, or a composite such as
counterparty + facility. When you key coarser than one row per exposure, both sides
are aggregated up to that grain first (additive components summed, ratios recomputed).
Reconcile the class allocation line-by-line
To verify a split exposure portion-by-portion rather than just per exposure, add the class column to both keys:
legacy_keys = ["exposure_reference", "Asset_Class"]
our_keys = ["exposure_reference", "exposure_class"]
The class key is normalised and value_map-translated on the way into the join (so legacy
RRE matches our residential_mortgage). A portion that lands in a class on only one side
then shows as missing_left / missing_right — the precise "this exposure moved to a
different risk class" signal. (Declare [components.exposure_class] with the value_map
so the key can be translated.)
2. Run it from Python¶
from datetime import date
from pathlib import Path
from rwa_calc.api import CreditRiskCalc
calc = CreditRiskCalc(
data_path="/path/to/data",
framework="CRR",
reporting_date=date(2026, 12, 31),
permission_mode="standardised",
)
response = calc.reconcile("reconciliation.toml")
# Headline: do the two engines tie out?
print(response.collect_totals_tie_out())
print(response.collect_summary_by_component())
# Warnings (non-fatal) — e.g. a mapped column the legacy file didn't contain
for err in response.errors:
print(f"[{err.code}] {err.message}")
# The break worklist, largest first
breaks = response.collect_breaks_detail()
print(f"{breaks.height} break(s) to review")
# Full report to Excel (one sheet per view) or CSV (one file per view)
response.to_excel(Path("reconciliation.xlsx"))
response.to_csv(Path("reconciliation_out/"))
reconcile() accepts either a path to a .toml file (shown above) or a
ReconciliationSettings object built in code via api.load_reconciliation_config.
reconcile() embeds a full calculate() for our side. If you already hold a
completed run for the same parameters and data, pass it as calculation= and the
pipeline is not re-run — the reconciliation starts from that run's cached results:
prior = calc.calculate()
# ... later, same data on disk ...
response = calc.reconcile("reconciliation.toml", calculation=prior)
The seam trusts what it is given — verify freshness yourself, or go through
rwa_calc.api.run_index (which fingerprints parameters plus the input files'
size/mtime signature) the way the UI does.
3. Or use the interactive UI¶
The Reconciliation page in the app (served at /reconciliation) gives you the same
result without writing Python: enter your data path, edit the TOML mapping in the form, and
run. The run executes on a background worker and shows a live, stage-by-stage progress
stepper (the same one the calculator page uses) so a large parallel run no longer looks
like a frozen tab; when it finishes the page opens the report automatically. The report is an
aggregates-first overview — headline tie-out, per-component summary, the segment tables
and a ranked "biggest breaks" top-N — that never materialises the full diff, so it stays fast
for portfolios of any size. From there you drill down: each segment row opens the per-key
explorer (filter by bucket / class / approach / worst-component / key, sort any column, and
page through the full row-level diff), and each key opens its single-loan forensic (every
component's legacy-vs-ours plus the explain / input drivers). A "Hide zero-gross-exposure
rows" toggle on the overview and the explorer drops immaterial our-only / legacy-only lines
from the breakdown counts (see Focusing on material differences).
CSV / Excel downloads of the full per-key detail remain available.
If the identical calculation already ran — from the calculator page, either half of
a comparison run, or a previous reconciliation's own embedded run (same data path,
framework, reporting date, permission mode, data format — and no input file changed
since) — the form offers a pre-ticked reuse checkbox so the engine run is
skipped and the reconciliation starts from that run's cached results. Freshness is
re-verified at submit time against the input files' size/mtime signature; any change
falls back silently to a full recompute. The reuse pool persists under
~/.rwa_calc/ (or $RWA_STATE_DIR), so it survives an app restart.
The same flow is available over HTTP for programmatic callers via POST /api/reconcile
(with GET /api/reconcile/export/{csv|excel} for the downloads). Pass the run_id
returned by POST /api/calculate to reconcile that registered run without re-running
the pipeline — an explicit run_id is strict: unknown ids 404 and a mismatched
framework/date, failed run or expired results 422 rather than silently recomputing.
Details: See Interactive UI for the full list of pages and how to start the server.
Output reference¶
ReconciliationResponse¶
| Member | Returns | Description |
|---|---|---|
success |
bool |
True when at least one component was reconciled. |
errors |
list[APIError] |
Non-fatal reconciliation warnings (see below). |
collect_totals_tie_out() |
pl.DataFrame |
Per additive component: legacy_total, our_total, delta, delta_pct. |
collect_summary_by_component() |
pl.DataFrame |
Per component: bucket counts, sum_abs_delta, break_rate. |
collect_class_allocation() |
pl.DataFrame |
Per risk class: our_ead/legacy_ead/delta_ead, our_rwa/legacy_rwa/delta_rwa/delta_rwa_pct. |
collect_class_allocation_by_method() |
pl.DataFrame |
The same, split by method within each class. Empty unless [components.approach] is mapped. |
collect_summary_by_bucket() |
pl.DataFrame |
Row-level row_bucket counts. |
collect_material_summaries() |
dict[str, pl.DataFrame] |
The count / tie-out summaries re-derived with zero-gross-exposure rows removed (keyed by bundle-frame name); empty when no components reconciled. |
collect_breaks_detail() |
pl.DataFrame |
Long-format break worklist, ranked by abs_delta. |
collect_component_reconciliation() |
pl.DataFrame |
Per-key forensic frame (legacy vs ours + explain/input, plus gross_exposure and is_immaterial). |
to_excel(path) / to_csv(dir) |
ExportResult |
Multi-sheet workbook / one CSV per view. |
has_breaks |
bool |
True when any row reconciled to a break. |
ReconciliationBundle¶
The underlying engine bundle (available as response.bundle). All frames are
pl.LazyFrame; errors is a list.
| Field | Description |
|---|---|
component_reconciliation |
Per-key: legacy_<c> / our_<c> / <c>_bucket per component, explain + input columns, row_bucket, worst_component, gross_exposure, is_immaterial. |
summary_by_component |
Headline per-component bucket counts and break rate. |
class_allocation |
Per risk class: sum(ours) vs sum(legacy) EAD/RWA and deltas — the asset-class allocation tie-out. Empty when class/EAD/RWA are unmapped. |
class_allocation_by_method |
The same allocation split by method (STD/FIRB/AIRB/SLOTTING/EQUITY) within each class — two-sided, post-guarantee. Summing a class's methods reproduces its class_allocation row. Empty unless [components.approach] is mapped. |
summary_by_bucket |
Row-level bucket counts. |
summary_by_exposure_class |
Break counts / sums by our exposure class. |
summary_by_approach |
Break counts / sums by our approach. |
breaks_detail |
Long-format break worklist, ranked by materiality. |
totals_tie_out |
Per additive component: sum legacy vs sum ours. |
Tolerances & data-quality warnings¶
Each numeric component has a sensible default tolerance — 1% relative for money amounts
(EAD, RWA, expected loss) and a tight absolute tolerance for rates (risk weight, PD,
LGD, CCF, supporting factor). Override any of them per component in the mapping with
tol_kind ("rel" or "abs") and tol. Categorical components match on the exact label
after case-folding and value_map synonyms.
Reconciliation never aborts on a data problem — issues are recorded as non-fatal warnings on
response.errors so they are visible rather than silent:
| Code | Meaning |
|---|---|
REC001 |
A mapped column was missing (legacy or ours); that component is skipped. |
REC002 |
Legacy key(s) had multiple rows; the lines are aggregated to the key grain (additive components summed, ratios recomputed) — symmetric with our side, never dropped. |
REC003 |
A declared join key column was not found; reconciliation cannot proceed. |
REC004 |
A key aggregated rows of differing class/approach — on our side (a coarse key) or the legacy side (an exposure split across classes). The categorical shown is the first in the group; reconcile at the (exposure × class) grain to separate them. |
REC005 |
Zero key overlap: every row is one-sided, so the join matched nothing. Almost always a key-mapping mistake (case, zero-padding, '123' vs '123.0'), not a real break. |
REC006 |
Our side carries non-finite (NaN/inf) values. They are treated as 0 in the totals so one bad row cannot blank a column, and the affected components are named here. |
REC007 |
The mapped legacy approach values do not resolve to a methodology (STD/FIRB/AIRB/SLOTTING/EQUITY) — or some rows have none at all — so the by-method allocation shows them as legacy-only lines and its deltas overstate the difference. The warning names the raw values from your file: add a [components.approach] value_map translating them to our approach labels (or populate the column). |
Related¶
- Output Schemas — the per-exposure columns reconciliation reads from.
- Comparison & Impact Analysis — CRR vs Basel 3.1 (a different kind of comparison).
- Interactive UI — the Reconciliation page and the other app surfaces.
- Service API —
CreditRiskCalcreference.