Supervisory validation rules — credit-risk extract¶
Machine-readable extracts of the two supervisory validation-rule sets that apply to the
templates this project produces. These JSON files are the committed artefact; the
source workbooks live in docs/assets/ and are gitignored, so nothing downstream (tests,
CI, tooling) needs the raw spreadsheets.
| File | Framework | Rules | Live |
|---|---|---|---|
crr-eba-v3.0-credit-risk.json |
CRR (EBA DPM 3.0) | 1,011 | 588 |
basel31-boe-v4.0.0-credit-risk.json |
Basel 3.1 (BoE banking v4.0.0) | 820 | 808 |
What these are for
A validation rule is the supervisor's own arithmetic check on a submitted return —
"column 0090 must equal the sum of columns 0050 to 0080". They are the most precise
available statement of how the reporting templates are expected to tie out, so they
double as an independent test oracle for the COREP and Pillar 3 output this project
produces. Both files are generated by
scripts/extract_validation_rules.py.
Looking up a rule¶
Both files load with the standard library and no encoding argument (see Encoding):
import json
from pathlib import Path
BASE = Path("src/rwa_calc/reporting/validations/rules")
eba = json.load(open(BASE / "crr-eba-v3.0-credit-risk.json"))
boe = json.load(open(BASE / "basel31-boe-v4.0.0-credit-risk.json"))
Which rules apply to a template? Match on the tables list — a rule is included if
any of its tables matches, so always test with any(...) rather than tables[0]:
live = [r for r in eba["rules"] if r["status"] == ["live"]]
c07 = [r for r in live if any(t.startswith("C 07.00") for t in r["tables"])]
# 195 live rules, of which 124 are ERROR and 71 WARNING
Never evaluate a formula on its own
Neither source's formula is self-contained. An EBA formula needs its rows/columns
scope, and 79 BoE expressions carry no row/column binding at all — theirs lives in
scope. Reading only the formula turns a one-cell check into a whole-table assertion.
See the expression is not self-contained before writing an evaluator.
Always filter on status
The extracts deliberately retain deactivated and deleted rules so that history is not
silently lost. Anything asserting current behaviour must filter to
r["status"] == ["live"] first — 423 of the 1,011 EBA rules are not live.
What does a rule actually check? A rule with a row scope is evaluated once per listed row, and the formula addresses the other axis:
rule = next(r for r in live if r["id"] == "v0305_m")
rule["rows"] # ['0010', '0020', '0030', '0040', '0050', '0060', '0070', '0080']
rule["formula"] # '{c0090} = {c0050} + {c0060} + {c0070} + {c0080}'
reads as: for each of those eight rows of C 07.00.a, column 0090 must equal the sum of
columns 0050, 0060, 0070 and 0080. See Formula grammar for the full
reference syntax.
Cross-referencing the two frameworks. Most BoE rules name their EBA ancestor, which is how you find the CRR equivalent of a Basel 3.1 check (and vice versa):
by_id = {r["id"]: r for r in eba["rules"]}
pairs = [(b, by_id[b["eba_equivalent"]]) for b in boe["rules"]
if b["eba_equivalent"] and b["eba_equivalent"] in by_id]
# 426 BoE rules resolve to an EBA rule held in this extract
# e.g. boe_b0190 -> v5745_q
427 BoE rules carry a single eba_equivalent; 426 of those resolve within this extract, the
odd one pointing at a rule on a table outside the credit-risk filter. See the
eba_equivalents caveat for the 35 rules that name several.
Regeneration¶
uv run python scripts/extract_validation_rules.py # rewrite both JSON files
uv run python scripts/extract_validation_rules.py --check # CI: fail if they would change
uv run python scripts/extract_validation_rules.py --sample 2 # eyeball the parse
--check re-extracts from the workbooks and exits non-zero if the committed JSON differs.
It needs both workbooks present in docs/assets/; fetch them with
uv run python scripts/download_docs.py.
Encoding¶
Both files are pure ASCII (verified: zero bytes > 127). The source labels contain
typographic dashes, curly quotes and — in one case — a zero-width non-joiner; these are
emitted as \uXXXX escapes rather than raw UTF-8. This is deliberate. On a Windows default
locale (cp1252) the idiomatic json.load(open(path)) raises UnicodeDecodeError on a
UTF-8 artefact, so a non-ASCII file would force every consumer to remember
encoding="utf-8". As committed, these load correctly under any locale:
import json
rules = json.load(open("src/rwa_calc/reporting/validations/rules/crr-eba-v3.0-credit-risk.json"))["rules"]
The escapes decode back to the original characters, so string values are lossless. Note that
45 BoE short_label values contain non-ASCII text, and boe_b0452's label begins with an
invisible zero-width non-joiner (U+200C, serialised as \u200c) — strip or normalise
labels before exact-matching on them.
Provenance¶
Source 1 — EBA (CRR framework)¶
- File:
docs/assets/eba-validation-rules.xlsx - Sheet:
v3.0(3.0.1)— one sheet per DPM release; the workbook carries 19 of them and only this one is read.3.0(3.0.1)is the framework version in force for CRR reporting. - Publisher: EBA
- Download URL:
https://www.eba.europa.eu/sites/default/files/2026-06/12d2a6ae-9f58-47ab-a684-cdc9924ed4aa/%28up%20to%203.5%29%20EBA_validation_rules_2026-06-10.xlsx - Landing page: https://www.eba.europa.eu/risk-and-data-analysis/reporting/reporting-frameworks
- Retrieved: 2026-08-01
- Sheet size: 8,935 rules, of which 1,011 match the credit-risk filter.
Filter — keep a rule if any of its T1..T7 table codes starts with:
Note the space after C in this workbook (C 07.00.a).
Source 2 — BoE (Basel 3.1 framework)¶
- File:
docs/assets/boe-validation-rules-banking-reporting-v4.0.0.xlsx - Sheet:
banking_reporting(the other sheet,Note, is prose and is ignored) - Publisher: Bank of England
- Download URL:
https://www.bankofengland.co.uk/-/media/boe/files/prudential-regulation/regulatory-reporting/banking/2026/february/boebankingtaxonomyvalidationsv400.zip - Archive member:
Bank of England Banking Taxonomy Validations Banking reporting v4.0.0.xlsx— the workbook ships inside the taxonomy zip;scripts/download_docs.pyextracts it. - Landing page: https://www.bankofengland.co.uk/prudential-regulation/regulatory-reporting/regulatory-reporting-banking-sector
- Retrieved: 2026-08-01
- Sheet size: 1,490 rules, of which 820 match the credit-risk filter.
Filter — keep a rule if any of its T1..T4 table codes starts with:
There is no space after C in this workbook (C34.02.01.01, OF07.00.01.01). A rule is
kept when any of its tables matches, so rules that also touch out-of-scope tables
(C01.00.01.01, C03.00.01.01, OF34.07.01.01, …) are retained in full with all their table
codes listed.
Counts¶
EBA¶
| Status | Rules |
|---|---|
live |
588 |
deactivated |
222 |
deactivated + deleted |
117 |
deleted |
46 |
deactivated + not_in_xbrl |
20 |
not_in_xbrl |
17 |
deactivated + deleted + not_in_xbrl |
1 |
| Total | 1,011 |
Severity: 505 ERROR / 506 WARNING overall; 276 ERROR / 312 WARNING among live rules.
Rule types: Manual 543, Identity 167, Hierarchy 101, Sign 91, eQuivalence 77, Nonexistence check 21, Allowed values for metric 6, Unique identifier 4, Allowed values for cell(s) 1.
Reactivation caveat. live means no value in Deactivated on, Deleted or
Not Implemented in XBRL. But 220 matched rules carry both a deactivation date and a later
reactivation date, and 153 of those are not deleted — they were switched off and then switched
back on. They are reported as deactivated, and filter.live_or_reactivated (741) gives the
wider "not permanently withdrawn" count. Consumers wanting currently enforced rules should
read reactivated_on rather than trusting status alone.
BoE¶
All 820 matched rules have Deactivated = no. 808 are included in XBRL; the remaining 12 are
flagged not_in_xbrl and so report as non-live, which is why live is 808 while
not_deactivated is 820.
Severity: 514 ERROR / 306 WARNING. 34 rules set always_execute.
462 rules carry a DESCRIPTION label; 427 of those resolve to exactly one EBA rule id.
Precondition and Simplified Precondition are empty for all 820 matched rules, as are
Where and Join. Scope is populated on 466 of them. The fields are still emitted (as
null) so the schema stays stable if a future taxonomy release starts using them.
Row and column id normalisation¶
Decision: ids are preserved verbatim. Sub-4-digit ids are never zero-padded.
The EBA sheet is inconsistent — most rules scope rows/columns with 4-digit ids (0010), but
42 of the 1,011 matched rules use 3-digit ids (070). Zero-padding 070 to 0070 would be
wrong, and the evidence is unambiguous:
- No live rule uses them. All 42 sub-4-digit rules are
deletedand/ordeactivated, most on 2014-04-16. Live rules: 588, of which 0 use legacy ids. - The workbook records the renumbering as an explicit event. The
Last Changecolumn holds the literal value4 digitsfor 564 of the 1,011 matched rules — the DPM was renumbered from 3- to 4-digit ids, rules were migrated in place, and the ones that were not migrated were deleted at that point. - The mapping is not a zero-pad. Deleted rule
v0414_mscopes(010-140)with formula{c105} >= {c030}; the live C 07.00.a column set contains no0105. Deletedv1664_mreferencesc255/c260; there is no0255/0260either. But deletedv1639_mreferences215/220, and0215/0220are real modern columns. So a blind zero-pad would silently invent a plausible-looking but unverified equivalence for some ids while producing dangling references for others — the worst of both outcomes.
Each rule therefore carries has_legacy_ids (true when any scope token is not exactly 4
digits and contains a digit run shorter than 4). Filter on status == ["live"] and the
question disappears entirely.
Two further scope forms exist, both only on non-live rules:
- Ranges —
(010-140),(0010-0095). Kept verbatim as a single token and flagged withhas_id_ranges; they are not expanded into the individual ids they span. 6 rules, 0 live. (All)— a literal "every row/column/sheet" scope. Represented asrows_scope: "all"with an emptyrowslist. 211 live rules use it on some axis, of which 61 use it on the row or column axis (it is the common case on the sheet axis).
JSON schema¶
Both files share the top-level shape:
{
"source": {
"framework": "CRR", "publisher": "EBA",
"file": "eba-validation-rules.xlsx", "sheet": "v3.0(3.0.1)",
"url": "...", "retrieved": "2026-08-01", "framework_version": "3.0(3.0.1)"
},
"filter": {"table_prefixes": ["C 02.00", "..."], "matched": 1011, "live": 588},
"rules": [ ... ]
}
EBA rule object¶
| Field | Type | Notes |
|---|---|---|
id |
str | e.g. v0305_m |
severity |
str | ERROR / WARNING (upper-cased from the sheet's Error/Warning) |
type |
str | Manual, Hierarchy, Sign, Identity, eQuivalence, Nonexistence check, … |
status |
list[str] | ["live"], or any of deactivated / deleted / not_in_xbrl |
tables |
list[str] | T1..T7, blanks dropped, e.g. ["C 07.00.a", "C 07.00.b"] |
rows |
list[str] | scope ids, verbatim |
rows_scope |
str | none / all / list |
columns, columns_scope |
list[str], str | as rows |
sheets, sheets_scope |
list[str], str | the z-axis (sheet) scope |
formula |
str | null | see grammar below |
prerequisites |
str | null | apply the rule only if these tables are reported |
if_value_missing |
str | null | treat as zero/empty string / do not run rule / not applicable |
arithmetic_approach |
str | null | Interval / Point / Mixed / Not applicable |
narrative |
str | null | human-readable error message (populated on 122 live rules) |
replaces |
str | null | superseded rule id(s) |
changed_in_release, last_change |
str | null | provenance from the sheet |
deactivated_on, reactivated_on |
str | null | ISO date |
has_legacy_ids, has_id_ranges |
bool | see normalisation above |
BoE rule object¶
| Field | Type | Notes |
|---|---|---|
id |
str | e.g. boe_b0190 |
severity |
str | ERROR / WARNING, parsed out of Severity and modules |
severity_modules |
list[str] | the module codes from the same cell, e.g. ["PRA001"] |
status |
list[str] | ["live"] or ["not_in_xbrl"] (none are deactivated) |
tables |
list[str] | T1..T4, blanks dropped |
expression |
str | null | Simplified Expression — the readable primary form |
expression_raw |
str | null | the full Expression; keeps the interval operators (below) |
precondition, precondition_raw |
str | null | simplified / raw; both empty in this extract |
scope, where, join |
str | null | scope(...) iteration domain; where/join empty here |
short_label |
str | null | the SHORT_LABEL(en) segment |
description |
str | null | the DESCRIPTION(en) segment, verbatim |
eba_equivalent |
str | null | the corresponding EBA rule id, only when exactly one |
eba_equivalents |
list[str] | every EBA id found in description (see caveat) |
error_message |
str | null | the BUSINESS(en) segment of Error message |
labels |
dict | all parsed KIND(en) segments, {"SHORT_LABEL": ..., "DESCRIPTION": ...} |
include_in_xbrl, always_execute |
bool | |
owner, framework_code, framework_version_code |
str | null | BOE, banking, banking_reporting |
eba_equivalents caveat. The DESCRIPTION segment usually holds one EBA id (v5745_q),
but 35 rules hold an expression over several: an enumeration (v10293_s, v10295_s), a sum
(v2042_s + v10312_s + v10314_s + v10485_s) or a range (v3354_i to v3370_i).
eba_equivalents lists the ids appearing literally in the text, so for a range it captures the
two endpoints, not the rules in between. Read description when the distinction matters.
eba_equivalent is populated only for the unambiguous single-id case (427 rules).
Formula grammar¶
EBA¶
A rule's formula is evaluated against a scope. A rule with a row scope is evaluated once
per listed row, and the formula's column references resolve within that row; likewise a
column-scoped rule is evaluated once per listed column. This is why the two axes are usually
mutually exclusive — the scoped axis is the loop, the formula addresses the other one.
| Form | Meaning |
|---|---|
{c0090} |
column 0090 of the current row — used by row-scoped rules |
{r0010} |
row 0010 of the current column — used by column-scoped rules |
{r0030, c0010} |
a fully-qualified cell in the rule's own table |
{C 07.00.a, c0200} |
column 0200 of table C 07.00.a, at the current row |
{C 08.01.a, r0070, c0020} |
a fully-qualified cell in another table (the commonest form) |
{C 08.01.a, r0010, s0003} |
sNNNN addresses the sheet (z) axis |
{C 07.00.a} |
the whole table — used by Sign rules, e.g. {C 07.00.a} <= 0 |
{C 08.02, rNNN} |
rNNN is an open-row wildcard for templates with a variable row count, always inside sum(...) / where(...) |
Operators seen in this extract: = >= <= > < !=, arithmetic + - * /, and the functions
sum(...), where(...), abs(...), max(...), min(...), empty(...), plus
if … then … conditionals and and.
arithmetic_approach qualifies comparison: Interval means the comparison tolerates rounding
(the reported value is treated as an interval around its rounded figure); Point means exact.
Example — v0305_m, a row-scoped rule over C 07.00.a:
rows = (0010;0020;0030;0040;0050;0060;0070;0080)
formula = {c0090} = {c0050} + {c0060} + {c0070} + {c0080}
reads as: for each of those eight rows, column 0090 must equal the sum of columns 0050, 0060, 0070 and 0080.
Legacy 3-digit rules are self-consistent — do not half-migrate them
Rules predating the 2014 DPM renumbering use 3-digit ids in both the scope columns
and the formula body, so they resolve against themselves. v0304_m has rows
(070;080) with formula {c010} >= {c020}; v1784_h has rows (290;310) with
{c040} = +{c010} + {c030}. Zero-padding the scope to 0070 while leaving the
formula's {c010} untouched would produce references that no longer resolve against
each other — which is why this extract pads neither.
Every one of these rules is dead, so it never bites a consumer filtering to
status == ["live"]. Counted two ways: 42 rules carry a 3-digit id in the
rows/columns scope (flagged has_legacy_ids), and 153 carry one somewhere in
the formula body — most legacy notation lives in the formula, not the scope, which is
why a scope-only count understates it. 0 are live under either count. The
distinction matters only if you ever evaluate deactivated rules for historical
comparison, and then you must treat a rule's notation as a single generation.
One caveat if you do: a handful of non-live rules mix generations in one formula —
v6188_m has {C 103.00, r999} <= {C 08.01.a, r0010, s0001}. The source also contains
at least one malformed id, c00280 in v6191_m. Both are reproduced verbatim.
Example — v0309_m, a row-scoped cross-table rule:
tables = C 07.00.a, C 07.00.b
rows = (0090;0110;0130)
formula = {C 07.00.a, c0200} = {C 07.00.b, c0210}
BoE¶
References are keyed maps rather than positional:
| Key | Meaning |
|---|---|
t |
table code, e.g. OF09.01.01.01 |
r, c |
row / column id |
z |
sheet (z-axis) id, e.g. z: 0002 |
filter |
dimensional restriction, e.g. [eba_dim:CEG] = [eba_GA:x1] (counterparty geography) |
dv |
default value substituted when the cell is not reported (raw expression only) |
id |
binding name (v0, v1) referenced from the error message as {$v0} |
seq, f, fv |
sequence flag, framework code, framework version (raw expression only) |
scope(...) wraps the same reference syntax with multi-valued r:/c:/z: lists and defines
the domain the rule iterates over — the BoE analogue of the EBA row/column scope columns.
The expression is not self-contained — apply scope first
79 of the 820 BoE rules have an expression with no r:/c: binding at all; the row,
column and sheet coordinates live entirely in scope. All 79 have scope populated.
Evaluating expression alone turns a single-cell check into a whole-table assertion and
produces mass false positives.
boe_b0529 is the canonical example. Its expression is:
which says nothing useful on its own. The binding is in scope:
Together they mean row 0140 / column 0220 of OF07.00.01.01 must be zero, on each of the
listed sheets. Its EBA equivalent v0316_m states the identical check inline —
{r0140, c0215} = 0 — which is the clearest illustration of the grammar difference: the
EBA puts the coordinates in the formula, the BoE hoists them into scope. (The column
differs, 0215 vs 0220, because the templates were renumbered between frameworks.)
Always evaluate scope, and where / precondition where present, before the
expression. Those three are empty across all 820 rules in this extract, but a future
taxonomy release may populate them, so handle them rather than assuming they are null.
The EBA has the same trap, in mirror image
An EBA formula is equally incomplete on its own: 161 live formulas contain no
r-reference (158 of them carry an explicit rows scope) and 202 contain no
c-reference (199 carry a columns scope). {c0090} = {c0050} + ... is meaningless
until you loop it over the rule's rows list. The difference is only presentational —
the EBA keeps the scope in dedicated spreadsheet columns, the BoE inlines it in a
scope(...) expression. Neither source lets you read the formula in isolation.
Interval operators. The raw Expression uses i=, i>=, i<=, i> for interval
(rounding-tolerant) comparison — the equivalent of the EBA Interval arithmetic approach.
The Simplified Expression collapses these to plain =, >=, <=, >, losing the
tolerance semantics. Use expression for reading, expression_raw when the comparison
semantics matter. In this extract 654 of the 820 raw expressions use an interval operator.
Example — boe_b0190, a cross-template consistency check:
{t: OF09.01.01.01, r: 0010, c: 0010, filter: [eba_dim:CEG] = [eba_GA:x1]}
= {t: OF07.00.01.01, r: 0010, c: 0010, z: 0002}
reads as: original exposure pre-conversion-factors for central governments/central banks
(sheet 0002 of OF07.00) must equal the corresponding domestic-geography cell in OF09.01.
Its eba_equivalent is v5745_q, the matching EBA rule in the CRR extract.
Severity semantics¶
Both publishers use the same two levels:
- ERROR — a hard validation failure; the submission is rejected.
- WARNING — a plausibility/quality check; the submission is accepted but the firm is expected to explain or correct the flagged value.
The EBA sheet spells these Error/Warning; both extracts normalise to upper case.