Covenant tests (ratios.covenants)¶
Answers the question "does the company respect its covenants?". Tests a list of financial
covenants, each on the numerator and the denominator that the loan agreement defines, against
a threshold at a test date, and returns for each covenant an explicit outcome (passed,
breached or not_tested with a code and the reason), the tested value and the headroom.
The method does not compute the standard ratios of the other groups: a covenant is tested on the agreement's own definitions (for example "consolidated EBITDA for the 12 months ending on the test date, before non-recurring items"), which often differ from the reported figures. The analyst computes the amounts under those definitions and gives them with the words of the agreement; Fairmark echoes both in the audit trail.
Fairmark never decides what the loan agreement decides. Where agreements differ, the clause is
an explicit input with no default: the outcome when the denominator is zero or negative
(if_denominator_not_positive) and the rounding of the tested value (rounding). Every
outcome therefore traces back to a clause of the agreement.
Covenant tests and analytical ratios¶
A covenant test can give a different number from the analytical ratio of the same name in
ratios.debt_service, and both are correct for their purpose. The
analytical ratio uses the reported aggregates under the conventions the analyst chooses
(balances, EBITDA and interest definitions). The covenant uses the definitions written in the
loan agreement, which commonly adjust EBITDA (non-recurring items, pro forma effect of
acquisitions and disposals), include or exclude items of debt (leases, shareholder loans),
cap the cash that may be netted, and fix the test period (often the 12 months ending on the
test date). The lender reads compliance only from the agreement's definitions, so the covenant
test never substitutes the analytical ratio for them.
Formula¶
The headroom is positive on the compliant side of the threshold and negative on the breach
side, whatever the direction of the covenant. At the threshold it is zero: an inclusive operator
(at_most, at_least) passes and a strict one (less_than, greater_than) breaches.
Precision. The outcome is decided exactly. Without rounding, it is the sign of
numerator − threshold × denominator, computed exactly in integer arithmetic (the denominator is
positive at that point), so a tested value that rounds to the threshold at 28 digits is still
classified on its exact value; value and headroom are shown to the 28 significant digits of the
decimal type, not rounded to the decimals of the agreement. With rounding, the tested value is
rounded as the agreement states, computed from the exact quotient (no intermediate rounding),
and the outcome is the exact comparison of that rounded value with the threshold; value is
the rounded value and value_unrounded the value before rounding. When the displayed headroom
rounds to 0 at 28 significant digits (or shows the other sign) while the exact comparison
decides otherwise, a convention and an explain note say so: "displayed headroom rounds to 0 at
28 significant digits; the exact comparison decides breached".
Zero or negative denominator. The tested value is not computed: value and headroom are null
and the outcome is the one stated by if_denominator_not_positive (breached, passed or
not_tested), and outcome_basis is if_denominator_not_positive, so an agent can tell an
outcome set by the agreement's rule from one decided on a tested value (tested_value). With
not_tested the code is denominator_not_positive and the reason says
whether the denominator is zero or negative.
Note: headroom_relative is headroom / threshold. For a maximum covenant with a positive
tested value it is also the fraction by which the denominator can fall, the numerator
unchanged, before the covenant is breached. For a minimum covenant the fraction by which the
numerator can fall, the denominator unchanged, is headroom / value (for example a minimum
interest cover of 4 tested at 5: headroom 1, the numerator can fall by 1 / 5 = 20%, while
headroom / threshold is 25%). Fairmark does not return headroom / value as a separate field;
it follows from headroom and value.
Example¶
from decimal import Decimal
import fairmark as fm
ltm = "for the 12 months ending on the test date"
r = fm.ratios.covenants(
covenants=[
{
"name": "net leverage",
"test_date": "2026-12-31",
"numerator": {"definition": "consolidated net financial debt", "amount": 1900},
"denominator": {"definition": f"consolidated EBITDA {ltm}", "amount": 600},
"operator": "at_most",
"threshold": "3.5",
"if_denominator_not_positive": "breached",
},
{
"name": "interest cover",
"test_date": "2026-12-31",
"numerator": {"definition": f"consolidated EBITDA {ltm}", "amount": 600},
"denominator": {"definition": f"net finance charges {ltm}", "amount": 175},
"operator": "at_least",
"threshold": "4",
"if_denominator_not_positive": "passed",
},
{
"name": "debt service cover",
"test_date": "2026-12-31",
"numerator": {"definition": f"cash flow available for debt service {ltm}", "amount": 700},
"denominator": {"definition": f"debt service {ltm}", "amount": 0},
"operator": "at_least",
"threshold": "1.2",
"if_denominator_not_positive": "not_tested",
},
{
"name": "senior leverage",
"test_date": "2026-12-31",
"numerator": {"definition": "senior net financial debt", "amount": 3504},
"denominator": {"definition": f"consolidated EBITDA {ltm}", "amount": 1000},
"operator": "at_most",
"threshold": "3.50",
"if_denominator_not_positive": "breached",
"rounding": {"decimal_places": 2, "mode": "half_up"},
},
]
)
leverage, cover, dscr, senior = r.covenants
assert leverage["outcome"] == "passed" # 1900 / 600 = 3.1666... <= 3.5
assert abs(leverage["headroom"] - Decimal("0.3333333333")) < Decimal("1e-10")
assert cover["outcome"] == "breached" # 600 / 175 = 3.4285... < 4
assert cover["headroom"] < 0 # negative: on the breach side
assert dscr["outcome"] == "not_tested" # zero debt service, as the agreement states
assert dscr["not_tested_code"] == "denominator_not_positive"
assert dscr["outcome_basis"] == "if_denominator_not_positive"
assert leverage["outcome_basis"] == "tested_value"
assert dscr["not_tested_reason"].startswith("denominator is zero")
# 3.504 rounds to 3.50 under the agreement, which passes at most 3.50
assert senior["outcome"] == "passed"
assert senior["value_unrounded"] == Decimal("3.504")
assert r.summary == {"passed": 2, "breached": 1, "not_tested": 1}
Inputs¶
| Field | Meaning | Default |
|---|---|---|
covenants |
List of covenants, at least one; each is tested on its own and the results keep the order | required |
covenants[].name |
Name of the covenant as in the loan agreement, not blank | required |
covenants[].test_date |
Test date, YYYY-MM-DD; the amounts refer to the period ending on it |
required |
covenants[].numerator |
definition (the words of the agreement, not blank) and amount (currency units) |
required |
covenants[].denominator |
Same as numerator |
required |
covenants[].operator |
at_most (≤), less_than (<), at_least (≥) or greater_than (>) |
required, no default |
covenants[].threshold |
In the unit of the ratio: a multiple (3.5) or a decimal fraction (0.30 for 30%) |
required |
covenants[].if_denominator_not_positive |
Outcome when the denominator is zero or negative, as the agreement states: breached, passed or not_tested |
required, no default |
covenants[].rounding |
Rounding of the tested value before the comparison, as the agreement states: decimal_places (0 to 6) and mode (half_up: halves away from zero; half_even: halves to the even digit), both required |
optional: no rounding |
Outputs¶
| Field | Meaning |
|---|---|
covenants |
One result per covenant, in the order of the input |
covenants[].name, test_date, operator, threshold |
Echoed from the input |
covenants[].outcome |
passed, breached or not_tested, decided exactly |
covenants[].outcome_basis |
What decided the outcome: tested_value, or if_denominator_not_positive when the denominator is zero or negative and the input rule applies; null when the covenant is not tested because of an overflow |
covenants[].value |
The value compared with the threshold: numerator / denominator to 28 significant digits, or rounded as rounding states; null when the denominator is not positive or the covenant is not tested |
covenants[].value_unrounded |
With rounding: numerator / denominator before the rounding; null otherwise |
covenants[].headroom |
Distance of value from the threshold in the unit of the ratio, positive on the compliant side; null when value is null |
covenants[].headroom_relative |
{"value": ..., "not_defined": ...}: headroom / threshold, decimal fraction, same sign as the headroom |
covenants[].not_tested_code |
denominator_not_positive or overflow; null when the outcome is passed or breached |
covenants[].not_tested_reason |
Why the covenant was not tested, in words; null when the outcome is passed or breached |
summary |
passed, breached, not_tested: how many covenants have each outcome |
Conventions (explicit, reported in explain.conventions)¶
| Convention | Options | Default |
|---|---|---|
| Definitions | numerator and denominator as defined by the loan agreement, given as amounts; no standard ratio is substituted | fixed |
| Test date | the amounts refer to the period ending on the test date | fixed |
| Operator | at_most, less_than, at_least, greater_than, stated per covenant |
required |
| Denominator zero or negative | breached, passed or not_tested, stated per covenant; value and headroom null |
required |
| Rounding | none, or decimal_places 0 to 6 with half_up or half_even, stated per covenant |
none |
| Precision | the outcome is decided exactly: on the exact tested value, or on the value rounded as the agreement states when rounding is given; value and headroom are shown to the 28 significant digits of the decimal type, and are rounded to the decimals of the agreement only when rounding is given | fixed |
| Negative numerator | compared as computed | fixed |
| Headroom sign | positive on the compliant side, negative on the breach side | fixed |
| Relative headroom | headroom / threshold; not defined when the threshold is zero or negative, or when there is no tested value | fixed |
| Overflow | the value, the rounded value or the headroom exceeds the range of the decimal type: not tested (overflow), never breached, and the other covenants are tested |
fixed |
Covenants are numbered from 1 in the audit trail. Each covenant adds its own test rule
("covenant 1, net leverage (2026-12-31): passes when the tested value ≤ 3.5 (maximum,
inclusive)"), its rule for a denominator that is not positive and its rounding rule, both
marked as coming from the input, a verdict step ("breached (600 / 175 at_least 4 is false,
decided exactly)"), the unrounded and rounded value when rounding is given and, when the
covenant is not tested, the reason; the three counts of the summary are steps too. A negative
numerator is compared as computed: a net cash position gives a negative leverage, which passes
a maximum; a negative EBITDA gives a negative cover, which breaches a minimum.
Not covered¶
- Covenants on a single amount (minimum net worth, maximum capital expenditure) have no denominator and are not tested by this method; see the roadmap.
- Equity cures are not modelled in version 1.
- Step-down schedules are expressed as one covenant per test date, each with its own threshold.
- The relative headroom on a negative threshold is not defined.
Errors¶
| Code | When |
|---|---|
FM-E001 |
covenants is empty; a blank name or definition; rounding.decimal_places above 6; a missing or unknown field (including operator, if_denominator_not_positive, or rounding.mode when rounding is given), or an unknown value |
FM-E009 |
A test_date that is not a valid YYYY-MM-DD date |
Accuracy¶
The outcome is exact: without rounding it compares numerator and threshold × denominator as integers; with rounding the rounded value is computed exactly from the quotient of the two amounts and compared with the threshold. The tested value is one decimal division, the headroom one subtraction and the relative headroom one division, each to the 28 significant digits of the decimal type.
Verified against¶
- Golden cases in
tests/golden/ratios.covenants/, whose expected values come fromscripts/reference_ratios.py(Python fractions for the outcome and the rounding, decimal at 50 digits for the printed values): a covenant set with one breach and the headroom of the others, a zero and a negative denominator under each value ofif_denominator_not_positive, 3.504 tested at most 3.50 with and without rounding to 2 decimals,half_upagainsthalf_evenon a half, both operators of each direction at the threshold, negative numerators and non-positive thresholds; a constructed case for overflow. - Unit tests on values that round to the threshold at 28 digits, for example 3.0000000000000000000000000001 / 3 tested at most 1, which is breached, and on a quotient whose 28-digit rounding would round the wrong way at 2 decimals.