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 decimal module — no third-party FX library is required for the conversion itself).
  • Input contract — one record in, one enriched record out:
FieldDirectionTypeNotes
price_rawinstrLocale-formatted source, e.g. "€1.234,56". Never a float.
source_currencyinstrISO 4217 alpha-3, e.g. EUR, JPY. Validate before use.
rateinDecimalUnits of base currency per 1 unit of source currency.
base_currencyinstrLocked reporting currency, e.g. USD.
price_baseoutDecimalQuantized to the base currency’s minor unit.
  • Environment assumption: the decimal context is configured once at module load, so every worker rounds identically. Validate source_currency against the canonical ISO 4217 Currency Codes list at ingestion, not inside the hot conversion path.

Step-by-Step Implementation

Locale-aware multi-currency to base-currency conversion pipelineA left-to-right pipeline: raw locale string is cleaned, a two-digit-decimal-tail test resolves the separators into an exact Decimal, which is multiplied by the FX rate and quantized once with ROUND_HALF_EVEN to the base currency's minor unit. A branch shows that values with no fractional tail are parsed as whole units before conversion.Raw locale string€1.234,56strip ⇒ 1.234,56right-most sep+ 2 digits?Exact Decimal1234.56no FX applied yet× exchange rate× 1.0850Decimal × DecimalQuantize → base1339.50 USDHALF_EVEN · minor unitYESNONo two-digit tail (e.g. ¥12,800)every separator is a grouper → 12800 whole units, then × rate

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 float before 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 instantiate Decimal only here. Passing a float directly to Decimal(0.1) reproduces the error — always Decimal("0.1").

  • Ambiguous three-digit groups. "1.234" is 1234 in German grouping but 1.234 as 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 become 0.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.