Skip to content

Currency conversion (declarative FX via <CurrencyConversion>)

Mondrian-4 supports a <CurrencyConversion> schema element that produces a converted measure as SUM(base_measure × exchange_rate), automatically picking the correct rate as of the fact row’s own date via a rate table with non-overlapping validity intervals. You declare what you want; the engine compiles the interval band join and aggregation for you.

Why declarative currency conversion?

Without <CurrencyConversion>, converting a measure to a different currency requires a hand-written calculated member that must re-implement the effective-date lookup every time the rate table structure changes:

<CalculatedMember name="Revenue (USD)" dimension="Measures">
<Formula>
Sum(
Existing [Calendar].[Month].Members,
[Measures].[Revenue] * LookupCube("[fx_rate]", ...)
)
</Formula>
</CalculatedMember>

With <CurrencyConversion> the same metric is:

<CurrencyConversion name="Revenue (USD)" measure="Revenue"
rateTable="fx_rate" rateColumn="rate"
rateType="ECB" rateTypeColumn="rate_type"
factCurrencyColumn="currency_id" rateCurrencyColumn="currency_id"
factDateColumn="month_key"
rateValidFromColumn="valid_from" rateValidToColumn="valid_to"
formatString="#,##0.00"/>

The loader validates that the named base measure and all referenced columns exist, generates the band join at load time, and throws a clear error rather than producing a silently wrong result.

Schema placement

<CurrencyConversion> elements are wrapped in a <CurrencyConversions> block inside a <MeasureGroup>, as a sibling to <Measures> and <DimensionLinks>:

<Cube name="Monthly Revenue">
<MeasureGroups>
<MeasureGroup name="Revenue" table="mm_monthly">
<Measures>
<Measure name="Revenue" column="revenue" aggregator="sum"/>
</Measures>
<DimensionLinks>
<ForeignKeyLink dimension="Calendar" foreignKeyColumn="month_key"/>
</DimensionLinks>
<CurrencyConversions>
<CurrencyConversion name="Revenue (USD)" measure="Revenue"
rateTable="fx_rate" rateColumn="rate"
rateType="ECB" rateTypeColumn="rate_type"
factCurrencyColumn="currency_id" rateCurrencyColumn="currency_id"
factDateColumn="month_key"
rateValidFromColumn="valid_from" rateValidToColumn="valid_to"
formatString="#,##0.00"/>
</CurrencyConversions>
</MeasureGroup>
</MeasureGroups>
</Cube>

Attribute reference

AttributeXML keyYAML keyRequiredDescription
namenamenameyesThe name of the generated converted measure. Appears in [Measures] like any other measure.
measuremeasuremeasureyesThe name of an existing <Measure> in the same measure group (the base value to multiply).
rateTablerateTablerate_tableyesPhysical table name that holds exchange rates. Must exist in the physical schema.
rateColumnrateColumnrate_columnyesColumn in rateTable that holds the numeric exchange rate.
rateTyperateTyperate_typeyesLiteral value used to filter the rate table to one rate source (e.g. "ECB", "BLOOMBERG").
rateTypeColumnrateTypeColumnrate_type_columnyesColumn in rateTable that holds the rate-type discriminator.
factCurrencyColumnfactCurrencyColumnfact_currency_columnyesColumn in the fact table that identifies the source currency of each row.
rateCurrencyColumnrateCurrencyColumnrate_currency_columnyesColumn in rateTable that identifies the currency the rate converts from.
factDateColumnfactDateColumnfact_date_columnyesColumn in the fact table used to determine which rate interval applies.
rateValidFromColumnrateValidFromColumnrate_valid_from_columnyesColumn in rateTable for the interval start (inclusive).
rateValidToColumnrateValidToColumnrate_valid_to_columnyesColumn in rateTable for the interval end (exclusive).
formatStringformatStringformat_stringnoMDX format string for the converted measure, e.g. "#,##0.00" or "$#,##0".

Effective-date interval data contract

The rate table holds one row per (currency, rate_type) combination per validity period. The engine performs a band join that selects exactly the rate whose interval contains the fact row’s date:

fact.factDateColumn >= rate.rateValidFromColumn
AND fact.factDateColumn < rate.rateValidToColumn
AND fact.factCurrencyColumn = rate.rateCurrencyColumn
AND rate.rateTypeColumn = '<rateType literal>'

The join is inherently 1-to-1 — one fact row matches at most one rate row — provided the rate table’s intervals are non-overlapping. This means the converted measure’s aggregation is simply SUM(fact.measure × rate.rate), and any unconverted measure queried alongside it is completely unaffected.

CREATE TABLE fx_rate (
currency_id VARCHAR(3) NOT NULL, -- e.g. 'EUR'
rate_type VARCHAR(16) NOT NULL, -- e.g. 'ECB'
rate DECIMAL(18,6) NOT NULL,
valid_from DATE NOT NULL, -- inclusive
valid_to DATE NOT NULL, -- exclusive
PRIMARY KEY (currency_id, rate_type, valid_from)
);

Use valid_to = '9999-12-31' (or the next period’s valid_from) as a sentinel for the current open interval so that the < valid_to predicate always works cleanly.

Backend requirement and fail-closed validation

Validation is fail-closed: the schema load is aborted with a clear error message for any of the following conditions.

ConditionError
measure does not name an existing measure in the measure groupCurrencyConversion "X": measure "Y" not found in measure group
rateTable does not exist in the physical schemaCurrencyConversion "X": rateTable "Y" not found in physical schema
Any of the named columns does not exist in the referenced tableCurrencyConversion "X": column "Y" not found in table "Z"
Calcite backend is not activeCurrencyConversion "X": requires Calcite backend

There is no silent wrong result — every misconfiguration is caught before the first query runs.

Worked example: Bank demo monthly revenue

The Bank demo ships a Monthly Revenue cube whose mm_monthly fact rows are denominated in EUR. An fx_rate table holds two yearly ECB rates:

currency_idrate_typeratevalid_fromvalid_to
EURECB1.102024-01-012025-01-01
EURECB1.202025-01-012026-01-01

The raw fact data:

YearRevenue (EUR)
2024600
2025750
Total1350

Schema declaration

<MeasureGroup name="Revenue" table="mm_monthly">
<Measures>
<Measure name="Revenue" column="revenue" aggregator="sum"/>
</Measures>
<DimensionLinks>
<ForeignKeyLink dimension="Calendar" foreignKeyColumn="month_key"/>
</DimensionLinks>
<CurrencyConversions>
<CurrencyConversion name="Revenue (USD)" measure="Revenue"
rateTable="fx_rate" rateColumn="rate"
rateType="ECB" rateTypeColumn="rate_type"
factCurrencyColumn="currency_id" rateCurrencyColumn="currency_id"
factDateColumn="month_key"
rateValidFromColumn="valid_from" rateValidToColumn="valid_to"
formatString="#,##0.00"/>
</CurrencyConversions>
</MeasureGroup>

Golden results

RowRevenue (EUR)Revenue (USD)Calculation
2024600660600 × 1.10 (ECB 2024 rate)
2025750900750 × 1.20 (ECB 2025 rate)
Grand total13501560660 + 900

The 2024 and 2025 rows use different rates because the band join picks the rate whose [valid_from, valid_to) interval contains each fact row’s month_key. The base Revenue (EUR) is unaffected and remains 1350.

Sample MDX query

SELECT
{ [Measures].[Revenue],
[Measures].[Revenue (USD)] } ON COLUMNS,
[Calendar].[Year].Members ON ROWS
FROM [Monthly Revenue]

Result:

YearRevenue (EUR)Revenue (USD)
2024600660.00
2025750900.00
All Time1,3501,560.00

Relationship to <CalculatedMembers>

<CurrencyConversion> compiles to a native SQL aggregation (SUM(fact.revenue × rate.rate)) rather than to an MDX <CalculatedMember>. This means:

  • The conversion is evaluated in the database, not in the MDX engine — it is as fast as any ordinary aggregate measure.
  • The converted measure appears in XMLA member enumerations alongside regular measures.
  • It can be referenced by <CalculatedMember> formulas (e.g. to compute a margin in the target currency).

If you need a formula that <CurrencyConversion> cannot express — for example, converting only a slice of a measure, or blending rates from multiple sources — use a plain <CalculatedMember> together with a separate scripted lookup. The two approaches can coexist in the same cube.

See Advanced — Calculated members for the full <CalculatedMember> reference.