Converting Multi-Currency Prices to Base Currency
A single scrape run routinely returns €1.234,56 from a German storefront, £99.99 from a UK listing, and ¥12,800 from a Japanese marketplace. None of these are comparable until they are rendered into one reporting currency as exact, auditable numbers — and the conversion has to be deterministic, because the same input must always produce the same output for a historical price series to mean anything. This page is the focused recipe for that single transformation: take one locale-formatted source string plus its currency and an exchange rate, and emit a fixed-point value in your base currency. It is a child task of the Currency Conversion & Exchange Rate Sync stage, and it must run before any promotional logic — see Parsing Complex Promotional Discount Structures — so that discounts are computed against a clean base price rather than a number that already mixes FX and coupon effects.
Prerequisites & Input Contract
This recipe assumes the upstream Scraping & Data Ingestion Workflows stage has already produced a structured record containing the raw price string (never a float — see the gotchas below) and a currency hint. Rate acquisition, caching, and freshness policy are owned by the parent Currency Conversion & Exchange Rate Sync guide; here the rate arrives as an already-validated Decimal.
- Python: 3.9+ (the standard-library
decimalmodule — no third-party FX library is required for the conversion itself). - Input contract — one record in, one enriched record out:
| Field | Direction | Type | Notes |
|---|---|---|---|
price_raw | in | str | Locale-formatted source, e.g. "€1.234,56". Never a float. |
source_currency | in | str | ISO 4217 alpha-3, e.g. EUR, JPY. Validate before use. |
rate | in | Decimal | Units of base currency per 1 unit of source currency. |
base_currency | in | str | Locked reporting currency, e.g. USD. |
price_base | out | Decimal | Quantized to the base currency’s minor unit. |
- Environment assumption: the
decimalcontext is configured once at module load, so every worker rounds identically. Validatesource_currencyagainst the canonical ISO 4217 Currency Codes list at ingestion, not inside the hot conversion path.
Step-by-Step Implementation
Step 1 — Pin the decimal context. Set precision and rounding mode globally so the result never depends on which worker ran the job.
import decimal
from decimal import Decimal, ROUND_HALF_EVEN
# Configure once, at import time, for pipeline-wide consistency.
decimal.getcontext().prec = 12 # generous headroom for intermediate math
decimal.getcontext().rounding = ROUND_HALF_EVEN # banker's rounding for fairness at scale
Step 2 — Resolve locale separators. A naive .replace(",", ".") corrupts thousands separators ($1,234.56 becomes 1.234.56). Detect the decimal separator by its position: the right-most separator immediately followed by exactly two digits is the decimal point; the other separator is the grouper.
import re
# Currency minor-unit map: how many fractional digits the currency legally has.
MINOR_UNITS = {"USD": "0.01", "EUR": "0.01", "GBP": "0.01", "JPY": "1", "BHD": "0.001"}
def normalize_locale_price(raw_price: str) -> Decimal:
"""Strip symbols, resolve locale separators, return an exact Decimal."""
cleaned = re.sub(r"[^\d.,]", "", raw_price) # drop currency symbols / spaces
if not cleaned:
raise ValueError(f"Unparseable price string: {raw_price!r}")
# Right-most separator followed by exactly 2 digits => decimal separator.
m = re.search(r"([.,])(\d{2})$", cleaned)
if m:
decimal_sep = m.group(1)
thousands_sep = "," if decimal_sep == "." else "."
cleaned = cleaned.replace(thousands_sep, "").replace(decimal_sep, ".")
else:
# No 2-digit fractional tail: treat every separator as a grouper (e.g. JPY).
cleaned = cleaned.replace(",", "").replace(".", "")
return Decimal(cleaned)
Step 3 — Multiply and quantize. Do the arithmetic in Decimal, then snap to the base currency’s minor unit exactly once, at the very end.
def convert_to_base(raw_price: str, source_currency: str,
rate: Decimal, base_currency: str = "USD") -> Decimal:
"""Locale-formatted source string -> exact base-currency Decimal."""
local_price = normalize_locale_price(raw_price)
if source_currency == base_currency:
converted = local_price # identity: still normalize + quantize
else:
converted = local_price * rate # exact fixed-point multiplication
quantum = Decimal(MINOR_UNITS.get(base_currency, "0.01"))
return converted.quantize(quantum, rounding=ROUND_HALF_EVEN)
Step 4 — Call it and inspect the output.
>>> convert_to_base("€1.234,56", "EUR", Decimal("1.0850"), "USD")
Decimal('1339.50')
>>> convert_to_base("$1,234.56", "USD", Decimal("1"), "USD")
Decimal('1234.56')
>>> convert_to_base("¥12,800", "JPY", Decimal("0.0064"), "USD")
Decimal('81.92')
The German and US inputs resolve their opposite separator conventions to the same numeric value; the yen input, having no two-digit fractional tail, is treated as a whole-unit amount before conversion.
Verification & Testing
Conversion bugs are silent — a wrong separator produces a plausible-looking number that is off by a factor of 100. Treat the function as financial code and assert against known-good fixtures.
import unittest
class TestConvertToBase(unittest.TestCase):
def test_european_format(self):
self.assertEqual(
convert_to_base("€1.234,56", "EUR", Decimal("1.0850"), "USD"),
Decimal("1339.50"))
def test_anglo_format(self):
self.assertEqual(
convert_to_base("$1,234.56", "USD", Decimal("1"), "USD"),
Decimal("1234.56"))
def test_no_fractional_tail_is_not_a_decimal(self):
# "1.234" with EU grouping must be 1234, not 1.234
self.assertEqual(normalize_locale_price("1.234"), Decimal("1234"))
def test_bankers_rounding_is_deterministic(self):
# 2.675 * 1 -> 2.68 under ROUND_HALF_EVEN at 0.01 quantum
self.assertEqual(
convert_to_base("2.675", "USD", Decimal("1"), "USD"),
Decimal("2.68"))
def test_empty_raises(self):
with self.assertRaises(ValueError):
normalize_locale_price("Call for price")
if __name__ == "__main__":
unittest.main()
Run with python -m unittest -v. For ongoing validation, diff a daily sample of converted EUR values against the publicly auditable European Central Bank Reference Rates; any systematic offset usually means a bid/ask spread has leaked into a rate that should be mid-market.
Edge Cases & Gotchas
Float contamination at the boundary. If any upstream code parsed the price into a binary
floatbefore this function, the representation error is already baked in (0.1 + 0.2 != 0.3). Keep the value as a string end-to-end and instantiateDecimalonly here. Passing afloatdirectly toDecimal(0.1)reproduces the error — alwaysDecimal("0.1").Ambiguous three-digit groups.
"1.234"is1234in German grouping but1.234as a decimal in some price displays. The two-digit-tail heuristic resolves the common retail case, but currencies with three minor digits (e.g.BHD) break it. Gate on the source currency’s known minor-unit count rather than guessing from the string alone.
def assert_minor_units(value: Decimal, currency: str) -> None:
expected = abs(Decimal(MINOR_UNITS.get(currency, "0.01")).as_tuple().exponent)
if -value.as_tuple().exponent > expected:
raise ValueError(f"{value} has more fractional digits than {currency} allows")
Malformed payloads. Strings like
"Call for price"or""must not silently become0.00— a zero corrupts every rolling average it touches. Raise and route the record to a dead-letter queue for analyst review rather than emitting a fake value.Already-discounted input. Applying an FX rate to a value that already includes a coupon distorts margin math. Convert the base price first; hand promotional decomposition off to Parsing Complex Promotional Discount Structures, and let jurisdiction-specific VAT/GST land afterward via Tax & Shipping Cost Normalization Rules.
Performance Notes
The conversion is O(n) in the length of the price string (dominated by the two regex passes) and allocates a handful of small Decimal objects per record — call it sub-microsecond once the regex is compiled. At millions of SKUs the cost is not CPU but memory: a Decimal carries more overhead than a native float, so store rates as strings in your cache and instantiate Decimal only at the moment of conversion. When throughput becomes the constraint, vectorizing this row-by-row routine into a batched columnar pass is the next step; once converted, every value should flow into Statistical Outlier Detection for Price Data so an FX spike or a missed separator surfaces as a >3σ deviation instead of a silent repricing error.
Related
- Currency Conversion & Exchange Rate Sync — the parent stage: dual-source FX ingestion, rate caching, and freshness policy that feeds this recipe.
- Standardizing Unit Pricing Across Marketplaces — the next normalization step once prices share a base currency: harmonizing units of measure.
- Tax & Shipping Cost Normalization Rules — applies jurisdiction-specific VAT/GST after conversion to reach true landed cost.
- Statistical Outlier Detection for Price Data — the validation gate that catches FX spikes and separator-parsing errors before dashboards ingest them.