Discounting Multi-Currency Lease Portfolios in Python
A focused how-to that discounts each currency's lease payments at that currency's own IBR, keeps amounts as (currency, Decimal), and translates the monetary liability to the presentation currency at closing spot.
Problem Statement Link to this section
This page answers one precise question: how do you discount a portfolio of leases denominated in different currencies in Python, so that each currency's payments are discounted at that currency's own borrowing rate and the resulting liabilities translate correctly to a single presentation currency? The instinct to convert every payment into one reporting currency and discount the whole book at one rate is exactly wrong — it discards the currency-specific time value of money and misstates every foreign liability. The correct build keeps each amount as a (currency, Decimal) pair, discounts each currency bucket with its own incremental borrowing rate, and only translates the finished liabilities — at the reporting-date closing spot rate, because the liability is a monetary item — into the presentation currency. The right-of-use asset stays at its historical rate. The currency-specific rate is the same incremental borrowing rate each lease locks at commencement. This is the runnable companion to multi-currency lease portfolio handling.
Standard Anchor Link to this section
Two provisions bound the discounting and one bounds the translation. ASC 842-20-30-1 / IFRS 16.26 measure the lease liability as the present value of the lease payments discounted at the rate implicit in the lease or the lessee's incremental borrowing rate; that IBR is currency-specific, so a euro lease is discounted at a euro rate and a yen lease at a yen rate. There is no portfolio-level rate spanning currencies.
IAS 21.23(a) then classifies the lease liability as a monetary item, retranslated at each reporting date at the closing spot rate, with the exchange difference recognized in profit or loss (IAS 21.28 / ASC 830-20-35). IAS 21.23(b) classifies the right-of-use asset as non-monetary, held at the commencement-date historical rate and never retranslated. The discounting must therefore finish inside each currency before any translation happens; measurement and translation are separate, ordered steps.
Formula / Algorithm Specification Link to this section
For a lease in currency
Each currency bucket runs this sum with its own
The consolidated portfolio liability is
Annotated Python Snippet Link to this section
The snippet binds every amount to its currency, discounts each bucket at its own rate, translates the monetary liability at closing spot and the non-monetary asset at historical rate, and ends with a terminal assert that the two rates were applied to the two items — proving no single blended rate slipped in.
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP, getcontext
getcontext().prec = 28
CENT = Decimal("0.01")
def q(x: Decimal) -> Decimal:
return x.quantize(CENT, rounding=ROUND_HALF_UP)
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str # ISO 4217, tagged at extraction
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency: # blending currencies is a bug
raise ValueError(f"cannot add {self.currency} + {other.currency}")
return Money(self.amount + other.amount, self.currency)
@dataclass(frozen=True)
class FXLease:
currency: str
payments: tuple[Decimal, ...]
ibr: Decimal # currency-specific (ASC 842-20-30-3)
commencement_spot: Decimal # historical rate for the ROU
closing_spot: Decimal # closing rate for the liability
def liability_local(lease: FXLease) -> Money:
"""PV in the lease's own currency at its own IBR (IFRS 16.26 / ASC 842-20-30-1)."""
r = lease.ibr
pv = sum((p / (Decimal(1) + r) ** t
for t, p in enumerate(lease.payments, start=1)), Decimal("0"))
return Money(q(pv), lease.currency)
def to_presentation(lease: FXLease, liability: Money, presentation: str) -> dict:
"""IAS 21.23: liability at closing spot, ROU at historical spot."""
return {
"liability": Money(q(liability.amount * lease.closing_spot), presentation),
"rou": Money(q(liability.amount * lease.commencement_spot), presentation),
}
if __name__ == "__main__":
book = [
FXLease("EUR", (Decimal("10000"),) * 12, Decimal("0.038"),
Decimal("1.08"), Decimal("1.10")),
FXLease("JPY", (Decimal("1500000"),) * 12, Decimal("0.011"),
Decimal("0.0067"), Decimal("0.0064")),
]
total = Money(Decimal("0.00"), "USD")
for lease in book:
local = liability_local(lease) # discounted in its OWN currency
pres = to_presentation(lease, local, "USD")
total = total + pres["liability"] # summing USD amounts only
# the monetary liability and non-monetary ROU used DIFFERENT rates
assert pres["liability"].amount != pres["rou"].amount
assert total.currency == "USD" # consolidation is single-currency
print("consolidated USD lease liability:", total.amount)
The assertions are the guardrail. The Money.__add__ guard makes it structurally impossible to sum a raw euro liability into a dollar total — the only additions that compile through consolidation are amounts already translated to the presentation currency. The per-lease assertion proves the liability was translated at the closing spot and the ROU at the historical spot, so a single blended rate can never have been used for both. Keep both checks in the build path so every portfolio is validated in CI.
Correct vs. Incorrect Discounting Approach Link to this section
The decisive choice is whether currency is respected before discounting. Per-currency discounting is correct; converting to one currency and discounting at one rate is the classic failure.
| Aspect | Per-currency IBR (correct) | Single blended rate (incorrect) |
|---|---|---|
| Order of operations | Discount in local currency, then translate | Translate to one currency, then discount |
| Discount rate | Each currency's own IBR | One portfolio-wide rate |
| Reflects | Currency-specific term structure | Nothing currency-specific |
| Liability accuracy | Correct per ASC 842-20-30-1 | Misstated for every foreign lease |
| Translation step | Closing spot on the finished liability | Baked in wrongly before discounting |
| ROU asset rate | Historical commencement spot | Often wrongly moved to closing rate |
The blended-rate approach is not a simplification with a small error — it applies the wrong time value of money to every non-reporting-currency lease and cannot be reconciled back to a compliant figure.
Gotcha / Failure Mode: Retranslating the Monetary Liability but Freezing It Like the Asset Link to this section
The subtle failure is treating the liability and the ROU asset the same way. The lease liability is a monetary item and must be retranslated at each closing spot rate; the ROU asset is non-monetary and must stay at the historical commencement rate. Freezing the liability at the historical rate (or, conversely, retranslating the ROU at the closing rate) inverts IAS 21.23.
Before (WRONG — both items frozen at the historical rate):
liability = Money(q(local.amount * lease.commencement_spot), "USD") # monetary!
rou = Money(q(local.amount * lease.commencement_spot), "USD")
# liability never moves with spot -> no FX gain/loss ever recognized
After (correct — liability at closing spot, ROU at historical):
liability = Money(q(local.amount * lease.closing_spot), "USD") # IAS 21.23(a)
rou = Money(q(local.amount * lease.commencement_spot), "USD") # IAS 21.23(b)
fx = q((lease.closing_spot - prior_spot) * local.amount) # to P&L
Debug checklist when a foreign liability looks wrong:
- Confirm the discount rate. Each bucket must use its own
ibr; a euro lease discounted at a dollar rate is the first thing to check. - Confirm the order. Discounting must finish in the local currency before any spot rate is applied.
- Confirm the two translation rates. Liability at closing spot, ROU at commencement spot — never the same rate for both.
- Confirm single-currency consolidation. Only presentation-currency amounts are summed; a raw local amount in the total is a bug the
Moneyguard should have caught.
Frequently Asked Questions Link to this section
Why not just translate everything to the reporting currency and discount once?
Because the discount rate is currency-specific. The incremental borrowing rate reflects the term structure and credit spread of borrowing in a particular currency, so discounting a euro payment stream at a dollar rate applies the wrong time value of money (ASC 842-20-30-3 / IFRS 16.26). Converting first also loses the clean separation between measurement and translation that IAS 21 requires. Discount each currency in its own currency at its own rate, then translate the finished liability at the closing spot rate.
Which spot rate translates the liability versus the ROU asset?
The liability is retranslated at the reporting-date closing spot rate because it is a monetary item (IAS 21.23a); the right-of-use asset stays at the commencement-date historical spot rate because it is non-monetary (IAS 21.23b). Using the same rate for both is the core mistake — it either suppresses the foreign-exchange gain or loss that should hit profit or loss, or invents a phantom movement in the asset. The two are meant to diverge over the term by the cumulative exchange difference.
How do I keep amounts from being added across currencies by accident?
Bind every amount to its currency in the type system. The Money dataclass in the snippet overrides addition to raise when the currencies differ, so a raw euro liability can never be summed into a dollar total. Only amounts that have already been translated to the presentation currency share a currency tag and therefore add cleanly. This turns a silent numeric error into an immediate, loud failure at the exact line where the mistake happens.
Related Link to this section
- Multi-currency lease portfolio handling — the parent topic covering currency tagging, functional versus presentation currency, and the full IAS 21 treatment.
- Present value calculation logic — the single-currency discounting step this how-to runs once per currency bucket.
- Lease document extraction and clause parsing pipelines — the section that tags each lease's currency during ingestion.