How to test FX markup: rate application and markup disclosure
Testing foreign exchange (FX) markup means recomputing every conversion quote from its recorded rate source, timestamp, and quote direction, then comparing the disclosed markup with the applied rate divided by the reference rate, minus one. The check that catches reference defects replays quotes created just before and after the European Central Bank reference rate publication, around 16:00 CET.
Where a correct rate becomes a wrong FX markup
A card quote at 11:00 on a Monday that measures its FX markup against the ECB rate published at 16:00 the same day discloses a percentage over a rate that did not yet exist. The disclosure goes equally wrong with a rate quoted in the opposite direction, or a cross rate built from two different days. The dealing engine converts correctly, the ledger balances, and the disclosed percentage is still wrong on every affected quote. A forex markup fee shown as an amount inherits the error, because the amount is derived from the same reference.
In the EU the reference is fixed by law for card payments. Regulation (EU) 2021/1230 Article 4(1) says that payment service providers and parties providing currency conversion at an ATM or at the point of sale "shall express the total currency conversion charges as a percentage mark-up over the latest available euro foreign exchange reference rates issued by the European Central Bank (ECB)", and that the mark-up "shall be disclosed to the payer prior to the initiation of the payment transaction". Article 4(2) adds publication of the mark-up on an electronic platform. Article 4(5) adds an electronic message after an ATM withdrawal or point-of-sale payment order in a Union currency other than the account currency, sent once in every month in which such an order arrives in that currency, with an opt-out under Article 4(6). Article 1(2) limits Articles 4 and 5 to payments "denominated in euro or in a national currency of a Member State other than the euro" that involve a currency conversion service. For credit transfers initiated online, Article 5 requires the estimated conversion charges and the estimated total in the account currency before initiation, with no ECB reference named.
These rules were inserted into Regulation (EC) No 924/2009 as Articles 3a and 3b by Regulation (EU) 2019/518, with Article 3a(1) to (4) applying from 19 April 2020 and the electronic message from 19 April 2021. Regulation 2021/1230 repealed and codified Regulation 924/2009; its Annex II maps Article 3a to Article 4 and Article 3b to Article 5. Texts checked on 2026-09-22.
A wrong percentage on an in-scope card payment breaks Article 4(1) on every quote that uses the wrong reference. In remittance, the markup is also the revenue line, so a reference defect misstates both the disclosure and the margin report; the remittance segment page traces that money flow.
Procedure
1. Capture rate source, timestamp, and direction on every quote
A quote that stores only the applied rate cannot be tested after the fact. Require the quote response to carry the reference source, the publication date of the reference, the creation and expiry times, and the direction of the rate.
POST /v1/fx-quotes HTTP/1.1
Host: api.example.com
Authorization: Bearer sk_test_EXAMPLE_ONLY
Content-Type: application/json
{"account_currency": "SEK", "payment_currency": "PLN", "payment_amount": "500.00", "channel": "card"}{
"quote_id": "Q-1004",
"created_at": "2026-09-21T15:00:00Z",
"expires_at": null,
"applied_rate": {"value": "2.6420", "direction": "account_per_txn"},
"reference": {"source": "ECB euro foreign exchange reference rates", "ecb_date": "2026-09-21",
"cross": ["SEK", "PLN"], "value": "2.590167700436480588100160809"},
"markup_pct": "2.00",
"account_amount": {"value": "1321.00", "currency": "SEK"}
}Reject any quote where ecb_date or direction is missing. Without them, a disclosed markup has no recomputable basis.
2. Load the reference rates the quote could have used
The ECB reference rates page states that the rates are "usually updated at around 16:00 CET every working day, except on TARGET closing days", after a concertation procedure "around 14:10 CET". All rates are quoted as units of foreign currency per one euro. The page also says the rates "are published for information purposes only. Using the rates for transaction purposes is strongly discouraged." Keep the ECB feed as the disclosure reference and the dealing rate on its own source. Checked on 2026-09-22.
The rates below are the ECB values for Friday 18 September and Monday 21 September 2026, from the daily and 90-day XML files. Save them as ecb_rates.csv.
ecb_date,currency,units_per_eur
2026-09-18,USD,1.1460
2026-09-18,PLN,4.3635
2026-09-18,SEK,11.2915
2026-09-18,HUF,364.28
2026-09-21,USD,1.1490
2026-09-21,PLN,4.3530
2026-09-21,SEK,11.2750
2026-09-21,HUF,362.40The 90-day file drops trailing zeros (rate='1.149') where the daily file shows rate='1.1490'. Parse rates as decimals and never infer display precision from the string.
3. Normalise direction and build cross rates
State every rate as account currency per one unit of payment currency before any comparison. An ECB rate for a pair of two non-euro currencies is the ratio of two euro rates: SEK per PLN on 21 September 2026 is 11.2750 / 4.3530. Recital 7 of Regulation 2019/518 anticipates this: "A mark-up might have to be based on a rate derived from two ECB rates in the case of a conversion between two non-euro currencies." Keep the derived rate unrounded and take both legs from the same ecb_date.
4. Recompute each quote from the quote log
The synthetic quote log below mixes card payments and one transfer with a rate lock. Save it as fx_quotes.csv.
quote_id,channel,created_at,expires_at,executed_at,account_ccy,txn_ccy,txn_amount,rate_quote,applied_rate,executed_rate,ecb_date,disclosed_markup_pct,account_amount
Q-1001,card,2026-09-21T15:10:00Z,,,EUR,USD,100.00,account_per_txn,0.8834,,2026-09-21,1.50,88.34
Q-1002,card,2026-09-21T09:00:00Z,,,PLN,EUR,250.00,account_per_txn,4.4508,,2026-09-21,2.25,1112.70
Q-1003,card,2026-09-19T10:00:00Z,,,EUR,PLN,1000.00,txn_per_account,4.2800,,2026-09-18,1.91,233.64
Q-1004,card,2026-09-21T15:00:00Z,,,SEK,PLN,500.00,account_per_txn,2.6420,,2026-09-21,2.00,1321.00
Q-1005,transfer,2026-09-21T15:30:00Z,2026-09-21T15:32:00Z,2026-09-21T15:33:10Z,EUR,HUF,20000.00,account_per_txn,0.0028,0.0028,2026-09-21,1.47,56.00
Q-1006,card,2026-09-21T14:05:00Z,,,EUR,USD,40.00,account_per_txn,0.8850,,2026-09-18,1.42,35.40
Q-1007,card,2026-09-21T15:20:00Z,,,EUR,USD,12.50,account_per_txn,0.8834,,2026-09-21,1.50,11.05The checker reads both files, finds the ECB publication a quote was allowed to cite, recomputes the markup and the converted amount, and checks rate lock expiry. The 30-minute window around publication and the half-even rounding of converted amounts are our test design and product specification, not rule text.
# check_fx_quotes.py: python check_fx_quotes.py ecb_rates.csv fx_quotes.csv
import csv
import sys
from datetime import date, datetime, time, timedelta
from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN, getcontext
from zoneinfo import ZoneInfo
getcontext().prec = 28
FRANKFURT = ZoneInfo("Europe/Berlin")
PUBLICATION = time(16, 0) # ECB: "around 16:00 CET"; read as Frankfurt local time
WINDOW = timedelta(minutes=30) # our tolerance around the publication time
MINOR_UNITS = {"EUR": 2, "USD": 2, "PLN": 2, "SEK": 2, "HUF": 2} # ISO 4217 list one
DISPLAY_STEP = Decimal("0.01") # mark-up disclosed with two decimals
SPEC_ROUNDING = ROUND_HALF_EVEN # product specification, not a rule
UNION_CURRENCIES = {"EUR", "CZK", "DKK", "HUF", "PLN", "RON", "SEK"} # 2026
def load_ecb(path):
rates = {}
with open(path, newline="") as f:
for row in csv.DictReader(f):
day = date.fromisoformat(row["ecb_date"])
rates.setdefault(day, {"EUR": Decimal(1)})[row["currency"]] = Decimal(row["units_per_eur"])
return rates
def published_at(day):
return datetime.combine(day, PUBLICATION, tzinfo=FRANKFURT)
def acceptable_dates(created, rates):
"""ECB dates a quote created at `created` may cite as the latest available."""
published = sorted(d for d in rates if published_at(d) <= created)
ok = set(published[-1:])
for d in rates: # inside the window, the previous and the new rate are both accepted
if abs(created - published_at(d)) <= WINDOW:
ok.add(d)
ok.update(p for p in published[-2:] if p < d)
return ok
def reference(rates, day, account, txn):
"""Account currency per one unit of transaction currency, from two ECB rates."""
table = rates[day]
return table[account] / table[txn]
def check(row, rates):
problems = []
created = datetime.fromisoformat(row["created_at"].replace("Z", "+00:00")).astimezone(FRANKFURT)
cited = date.fromisoformat(row["ecb_date"])
basis = cited
if cited not in acceptable_dates(created, rates):
basis = max(d for d in rates if published_at(d) <= created)
problems.append(f"cites ECB {cited}; latest available at {created:%Y-%m-%d %H:%M %Z} was {basis}")
rate = Decimal(row["applied_rate"])
applied = rate if row["rate_quote"] == "account_per_txn" else 1 / rate
ref = reference(rates, basis, row["account_ccy"], row["txn_ccy"])
markup = (applied / ref - 1) * 100
if abs(markup - Decimal(row["disclosed_markup_pct"])) > DISPLAY_STEP / 2:
problems.append(f"disclosed {row['disclosed_markup_pct']}% but mark-up over ECB {basis} is {markup:.4f}%")
unit = Decimal(1).scaleb(-MINOR_UNITS[row["account_ccy"]])
expected = (Decimal(row["txn_amount"]) * applied).quantize(unit, rounding=SPEC_ROUNDING)
if Decimal(row["account_amount"]) != expected:
problems.append(f"account_amount {row['account_amount']} expected {expected}")
if row["expires_at"]:
expires = datetime.fromisoformat(row["expires_at"].replace("Z", "+00:00"))
executed = datetime.fromisoformat(row["executed_at"].replace("Z", "+00:00"))
if executed > expires and row["executed_rate"] == row["applied_rate"]:
problems.append(f"executed {executed - expires} after expiry at the locked rate")
return problems
def scope(row):
"""Which Regulation (EU) 2021/1230 article the row falls under, by the payment currency."""
if row.get("txn_ccy") not in UNION_CURRENCIES:
return "product policy"
return "Art. 4" if row.get("channel") == "card" else "Art. 5"
def main(ecb_path, quotes_path):
rates = load_ecb(ecb_path)
failed = 0
with open(quotes_path, newline="") as f:
for row in csv.DictReader(f):
try:
problems = check(row, rates)
except (KeyError, TypeError, ValueError, InvalidOperation, ZeroDivisionError) as exc:
problems = [f"unreadable row: {type(exc).__name__} {exc}"]
failed += bool(problems)
print(row.get("quote_id", "?"), scope(row), "PASS" if not problems else "FAIL; " + "; ".join(problems))
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main(*sys.argv[1:3]))Output on the two files above, run on 2026-09-22 with Python 3.13:
Q-1001 product policy PASS
Q-1002 Art. 4 FAIL; cites ECB 2026-09-21; latest available at 2026-09-21 11:00 CEST was 2026-09-18; disclosed 2.25% but mark-up over ECB 2026-09-18 is 2.0007%
Q-1003 Art. 4 FAIL; disclosed 1.91% but mark-up over ECB 2026-09-18 is 1.9509%
Q-1004 Art. 4 PASS
Q-1005 Art. 5 FAIL; executed 0:01:10 after expiry at the locked rate
Q-1006 product policy PASS
Q-1007 product policy FAIL; account_amount 11.05 expected 11.04Q-1002 cites a rate that did not exist at 11:00 in Frankfurt. Q-1003 stores an inverse quote. Q-1005 executes 70 seconds after its lock expired at the locked rate, so the provider carries the market move for that interval. Q-1007 rounds a half-cent tie up where the specification says half-even; the banker's rounding guide covers tie vectors per currency. Rows marked "product policy" pay in USD, outside Article 1(2), so their disclosure follows the product's own terms.
5. Check rate rounding on US remittance disclosures
12 CFR 1005.31(b)(1)(iv) requires the exchange rate "rounded consistently for each currency to no fewer than two decimal places and no more than four decimal places", and (b)(1)(vii) computes Total to Recipient with the rate "prior to any rounding of the exchange rate". The rule asks for the rate, with no mark-up percentage. For a synthetic USD 500.00 transfer at 17.284567 MXN per USD, the disclosed rate is 17.2846 and Total to Recipient is MXN 8,642.28; computing from the rounded rate gives MXN 8,642.30. The Regulation E page covers the rest of the disclosure. Text checked on 2026-09-22.
The pitfall: "latest available" moves at 16:00 and on weekends
Article 4(1) names "the latest available" ECB rates, and availability changes once per working day. A quote at 11:00 on Monday 21 September 2026 must cite Friday's rate, because Monday's appears around 16:00. A quote on Saturday 19 September cites Friday's rate as well. Systems fail this in two ways: a nightly job back-fills the reference with the rate of the posting date, or a cache keyed by calendar date serves today's rate before it exists. Both pass a test suite that runs after 16:00 on a weekday. Schedule fixtures at 11:00, 15:45, 16:05, Saturday, and a TARGET closing day.
The ECB page states times in CET, while Frankfurt runs on CEST (UTC+2) in September. Our checker reads the time as Frankfurt local time and accepts either rate inside 30 minutes of publication, provided the quote records which one it used. Treat the window as a product decision and write it in the specification.
Direction causes the second silent error. For a positive markup m over reference r, an inverse quote q equals r / (1 + m). Computing (r minus q) divided by r gives m / (1 + m), which is always smaller than m. In Q-1003 the correct figure is 1.95% over the ECB reference and the inverse formula shows 1.91%. The regulation does not define the formula, so our tests measure the markup on the rate the payer pays: account currency per unit of payment currency.
The same "rate in effect" logic applies outside the EU. FINTRAC Regulations section 125 converts a foreign currency transaction amount into Canadian dollars at the Bank of Canada rate "in effect at the time of the transaction", or, where the Bank publishes no rate for that currency, the rate used in the ordinary course of business (SOR/2002-184, checked on 2026-09-22). For credit transfers, the Payment Services Regulation final compromise text (Council document 8221/26) would require, for credit transfers and money remittance, the mark-up as an amount and a percentage over an "aggregated mid-market exchange rate" with "a maximum delay of 10 minutes" (Articles 5 and 13(1)(f)). On 2026-09-22 the Legislative Observatory shows "Awaiting Council's 1st reading position" with an indicative plenary date of 14 December 2026; the PSD3 and PSR page tracks the status.
Test matrix
| Case | Input | Expected |
|---|---|---|
| Quote before ECB publication | Monday 11:00 Frankfurt | Cites Friday's ECB date |
| Quote at a weekend | Saturday 10:00 UTC | Cites Friday's ECB date |
| Quote inside the publication window | Monday 16:05 Frankfurt | Either date, recorded on the quote |
| Inverse quote | 4.2800 PLN per EUR on a EUR account | Markup 1.95% over ECB, not 1.91% |
| Cross rate | SEK account, PLN payment | Reference 11.2750 / 4.3530, same ECB date |
| Rate lock at expiry | Execution 1 second before and after expires_at | Locked rate before; new quote or rejection after |
| Converted amount tie | EUR per USD 0.8834 on USD 12.50 | EUR 11.04 under half-even |
| US remittance rate rounding | 17.284567 MXN per USD | Rate 17.2846; recipient amount from the unrounded rate |
| Out-of-scope currency | USD card payment on a EUR account | Product disclosure policy applies |
Timing checks for cut-offs across time zones sit in the settlement timing guide. The financial calculation testing service delivers the reference model and discrepancy report for FX conversion formulas.
Related
Segments
Standards
Questions
What is FX markup?
Scope your testing
Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.
Scope your testing