Skip to content

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 chainexposure 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.

uv add rwa-calc
rwa-ui                 # then open http://localhost:8000/reconciliation

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).