Normalizing Multi-Currency Cost Reports with Decimal
The edge case this page solves is narrow and unforgiving: given one foreign-currency cost line and the pinned rate that applies to the day it was incurred, convert it into the production’s reporting currency so exactly that a completion guarantor (the completion-bond company underwriting delivery) can recompute the figure to the cent months later. The conversion itself is a multiplication, but doing it wrong is easy in three specific ways — building a Decimal from a binary float, rounding at the wrong step or in the wrong direction, and discarding the original amount and the rate that produced the result. Each of those turns a reproducible figure into one that reconciles cleanly to nothing. This walkthrough builds the conversion as a pure function over a validated line and a resolved rate, with Decimal money throughout and an audit payload that carries the original amount, the applied rate, and its provenance.
Prerequisites and Context
This page extends the subsystem documented in Currency & FX Normalization; it assumes the cost line has already been parsed, typed, and cost-coded upstream, and that the applicable rate has already been resolved — sourcing and pinning that rate is a separate concern, worked through in Sourcing and Pinning Daily FX Rates for Reproducible Conversion. It targets Python 3.11+ and uses the same deliberate dependency set as the rest of Cost Ingestion & Data Parsing Workflows: the standard-library decimal module for every monetary value, hashlib for the payload fingerprint, and Pydantic v2 (model_validate, field_validator) for the boundary schema. Records reach this function already validated against the contract enforced by Schema Validation & Error Handling, and each line keys to exactly one budget line through Cost Code Standardization. The one fact worth stating before any code: a foreign exchange rate is a ratio, not a price, so the convention for which currency is the base and which is the quote must be fixed and documented, or two engineers will multiply where the other divides.
Step-by-Step Implementation
Conversion begins at the boundary. The rate quotes units of the reporting (base) currency per one unit of the foreign (quote) currency, so the conversion is a single multiplication: reporting_amount = foreign_amount * rate. The subtlety is entirely in the types and the rounding. Every value enters as a Decimal built from a string, the multiplication runs at full precision, and the result is quantized to cents exactly once, at the end, with ROUND_HALF_UP — the rounding a trust account and a bank statement both use.
from __future__ import annotations
import hashlib
import json
from datetime import date, datetime, timezone
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
CENTS = Decimal("0.01")
class ConversionInput(BaseModel):
"""A foreign cost line plus the pinned rate resolved for its work date."""
model_config = ConfigDict(frozen=True)
line_id: str
original_currency: str # ISO 4217 of the amount paid, e.g. "EUR"
original_amount: Decimal # amount in original_currency
reporting_currency: str # the production's base currency, e.g. "USD"
fx_rate: Decimal # reporting units per 1 unit of original
rate_date: date # the pinned rate's effective date
source_id: str # provenance of the snapshot the rate came from
snapshot_hash: str # SHA-256 of the pinned rate table
@field_validator("original_amount", "fx_rate", mode="before")
@classmethod
def reject_float(cls, v: Any) -> Decimal:
# A float here silently imports binary rounding error; refuse it.
if isinstance(v, float):
raise ValueError("monetary/rate fields must be str/Decimal, never float")
return Decimal(str(v))
@field_validator("fx_rate")
@classmethod
def rate_positive(cls, v: Decimal) -> Decimal:
if v <= 0:
raise ValueError("fx_rate must be positive")
return v
def convert_cost_line(inp: ConversionInput) -> dict:
# Multiply at full precision; quantize to cents exactly once, at the end.
raw = inp.original_amount * inp.fx_rate
converted = raw.quantize(CENTS, rounding=ROUND_HALF_UP)
payload = {
"line_id": inp.line_id,
"reporting_currency": inp.reporting_currency,
"original_currency": inp.original_currency,
"original_amount": str(inp.original_amount),
"fx_rate": str(inp.fx_rate),
"rate_date": inp.rate_date.isoformat(),
"converted_amount": str(converted),
"source_id": inp.source_id,
"snapshot_hash": inp.snapshot_hash,
"computed_at": datetime.now(tz=timezone.utc).isoformat(),
"operator_id": "SYS_AUTO",
}
canonical = json.dumps(
{k: v for k, v in payload.items() if k != "computed_at"},
sort_keys=True, separators=(",", ":"),
).encode("utf-8")
payload["payload_sha256"] = hashlib.sha256(canonical).hexdigest()
return payload
Two decisions in that function are doing the real work. The multiplication happens at the Decimal context’s full precision and is quantized once, so no intermediate rounding is smuggled in; quantizing the amount before multiplying, or rounding the rate first, would produce a figure that no longer equals the amount times the rate. And the canonical hash is taken over everything except the wall-clock computed_at, so the fingerprint depends on the inputs and the result but not on when the function ran — which is what lets a re-run months later produce a byte-identical hash and prove nothing was altered.
The data flow is deliberately linear: a validated line and its resolved rate enter, the amount is multiplied and quantized, and one immutable payload carrying the original, the rate, and the converted figure is stamped with an audit hash.
Because convert_cost_line never touches the network and reads its rate from the validated input rather than a live lookup, identical inputs always produce an identical payload and an identical hash. That determinism is the whole point: a guarantor’s auditor can recompute a disputed conversion from the archived payload alone, with no dependency on what any rate feed says today.
Audit Trail Requirements
Every conversion — not merely the ones that later get questioned — must be serialized to a write-once store, so an examiner sees an unbroken record rather than only the exceptions. At minimum the persisted payload must carry: the line_id; the original_currency and original_amount as strings; the reporting_currency; the fx_rate and its rate_date; the converted_amount as a string; the source_id and snapshot_hash that pin the rate table used; a UTC computed_at; an operator_id; and the payload_sha256 fingerprint of the canonical payload. Persist the record to append-only, write-once storage — object storage with an immutability lock, or an event-sourced table with no in-place update — before any downstream ledger transaction commits, so a crash mid-run leaves a replayable record of intent rather than a silent gap. A later correction is a new compensating record, never an edit of the posted one, so the figure a report was built on stays recoverable. This is the same deterministic audit discipline the sibling engines rely on, which is why the serialization and hashing belong in one shared module rather than being re-implemented per pipeline.
The reason the original amount and the rate are both stored, rather than only the converted result, is that a completion guarantor reconciles from the source outward. A converted figure with no rate beside it is an assertion; a converted figure that carries £3,900.00, 1.2340, and a dated, hashed snapshot is a derivation anyone can check. The normalized figures assembled this way flow onward into the Completion Bond Reporting & Guarantor Analytics layer, where they become the reporting-currency cost lines a guarantor tests against budget.
Gotchas and Production Edge Cases
Float contamination. Never build a Decimal from a float literal. Decimal(1.234) imports the binary representation of 1.234, which is not exactly 1.234, and that error rides every line the rate touches. Read amounts and rates through their string form — Decimal(str(x)), as the boundary validator enforces — so a fraction-of-a-cent error compounded across a season of foreign invoices never becomes the variance a guarantor asks you to explain. Rejecting float at the boundary, rather than trusting callers to convert, is what makes the guarantee hold.
Rounding direction and timing. Quantize once, at the end, and fix the direction. Rounding the rate to a few places before multiplying, or quantizing the amount before applying the rate, produces a figure that no longer equals amount times rate — and the discrepancy is invisible until it aggregates into a total that will not tie out. ROUND_HALF_UP at cents is the convention that matches a bank’s own arithmetic; picking ROUND_HALF_EVEN or the default context rounding for money quietly diverges from the statement the production is reconciled against.
Rate provenance is part of the figure. A converted amount stored without its rate, rate date, and snapshot identity is not auditable — it cannot be recomputed, only re-guessed. Store fx_rate, rate_date, source_id, and snapshot_hash on every entry so the number is reproducible against the exact table that produced it, not against whatever the current rates happen to be. This is also what makes a re-run safe: because the hash is taken over the inputs and result, a retried batch that reprocesses an already-converted line produces the same fingerprint and can be deduplicated on (line_id, snapshot_hash, payload_sha256) rather than double-posting.
Rate direction and inversion. The single most common wrong number in cross-border conversion is an inverted rate — multiplying where the convention wanted a divide because “GBP/USD” was read the wrong way round. Fix the convention once (here, reporting units per one unit of the foreign currency, so conversion is always a multiplication), validate that the rate is positive, and sanity-check the magnitude: a converted figure that is three orders of magnitude off its foreign amount is almost always an inversion, not a market move.
Zero-decimal and minor-unit currencies. Not every currency has two minor-unit digits. Quantizing a JPY amount to 0.01 invents precision that the currency does not have. In a production that genuinely handles such currencies, drive the quantization exponent from the currency’s minor-unit metadata rather than hardcoding Decimal("0.01"), and record which exponent was applied alongside the figure so the rounding is itself reproducible.
Related Guides
- Currency & FX Normalization — the parent subsystem that routes foreign cost lines through this converter and owns the engine and quarantine structure.
- Sourcing and Pinning Daily FX Rates for Reproducible Conversion — how the pinned, hashed rate this function consumes is fetched, validated, and resolved by work date.
- Schema Validation & Error Handling — the boundary contract that guarantees a line reaches this function already typed and cost-coded.
- Async Batch Processing for Multi-Currency Shoots — the non-blocking worker pool that applies this conversion across a season of foreign lines without a live rate lookup.
- Completion Bond Reporting & Guarantor Analytics — the reporting layer that consumes the reporting-currency figures this conversion produces.
Up one level: Currency & FX Normalization, part of Cost Ingestion & Data Parsing Workflows.