fx-rates-correctness

Correctness pitfalls when working with foreign-exchange rate data in code — weekend and holiday gaps, forward-filled historical rates, timezone-of-record ambiguity, per-currency decimal precision and rounding, triangulated (derived) cross rates, and stale-rate detection. Use this skill whenever code fetches, stores, joins, backtests, or displays currency exchange rates — even a "simple" USD-to-EUR conversion — because naive FX handling produces silently wrong numbers, not errors. Also use when debugging why converted amounts, historical FX joins, or currency charts look wrong.

nusantara-ventures/exchangerate-skills1 installsMITSynced Aug 27

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: fx-rates-correctness
description: Correctness pitfalls when working with foreign-exchange rate data in code — weekend and holiday gaps, forward-filled historical rates, timezone-of-record ambiguity, per-currency decimal precision and rounding, triangulated (derived) cross rates, and stale-rate detection. Use this skill whenever code fetches, stores, joins, backtests, or displays currency exchange rates — even a "simple" USD-to-EUR conversion — because naive FX handling produces silently wrong numbers, not errors. Also use when debugging why converted amounts, historical FX joins, or currency charts look wrong.
license: MIT
---

# FX rates correctness

Exchange-rate bugs rarely throw. They return a plausible number that is wrong — a Friday rate silently used for Sunday, a JPY amount rounded like EUR, a backtest joining Tokyo closes against a CET fix. This skill catalogs the failure modes and the code patterns that avoid them. Examples use exchangerate.dev (keyless, `https://api.exchangerate.dev`), but the pitfalls apply to any FX source.

## 1. There is no rate for Saturday

FX reference rates (ECB, FRED) publish on **business days only**. Two different API behaviors exist, and confusing them corrupts data:

- **Snapshot endpoints forward-fill.** `GET /v1/2024-01-14?base=USD&symbols=EUR` (a Sunday) returns Friday's fix with `"is_forward_filled": true` and `data_updated_at` pointing at the Friday. If you store this row keyed by the Sunday date without checking the flag, your database now claims a Sunday fix existed.
- **Range endpoints omit.** `GET /v1/range?...` returns business days only — weekends are **absent rows, not nulls**. A naive "365 rows per year" assumption breaks; so does positional alignment against a calendar array.

Pattern:

```python
row = get_historical("2024-01-14", base="USD", symbols=["EUR"])
if row["is_forward_filled"]:
    # The requested date had no published fix. Decide explicitly:
    # - display/UX: fine to show, but label it ("as of Fri 2024-01-12")
    # - accounting/audit: use data_updated_at's date as the rate date, not the requested date
    # - research: usually better to drop the date or forward-fill deliberately in your own pipeline
    ...
```

The same trap exists on holidays (ECB holidays ≠ your market's holidays — Jan 1 has no fix, but neither does Easter Monday).

## 2. Forward-filling in research: fill AFTER the join, never before

For backtests and analytics, join FX to your asset data on exact dates first, then decide fill policy. Forward-filling FX before the join lets a Friday rate leak into Monday-morning calculations that should have used Monday's rate — look-ahead bias's quieter sibling. Standard pandas shape:

```python
fx = fetch_range(base="USD", symbols=["EUR"], start_date=..., end_date=...)  # business days only
df = prices.join(fx, how="left")       # exact-date join; weekend/holiday FX is NaN
df["EUR"] = df["EUR"].ffill()          # explicit, auditable fill as the LAST step
```

## 3. "The rate for date D" is ambiguous — pin the timezone-of-record

An ECB fix dated 2024-01-15 is a ~16:00 **CET** snapshot. A Tokyo close on the same calendar date happened 8 hours earlier; a New York close 6 hours later. Any join of FX against another time-series must state which timestamp convention both sides use. Ask: *rate as of which market's close?* If the answer matters (portfolio valuation, P&L), see the `portfolio-currency-translation` skill — mixing conventions shifts daily returns by up to a full day.

With intraday sources, use the response's own clock: exchangerate.dev returns `timestamp` and `data_updated_at` (UTC) on every call — store those alongside the rate instead of stamping rows with your server's local time.

## 4. Precision is per-pair, not global

JPY pairs quote at 2–3 decimal places; most others at 4–5. Two distinct concepts:

- **Rate precision** (`decimals`) — how many places the *quote* carries. Blanket-rounding all rates to 4 dp destroys JPY-pair information and adds noise elsewhere.
- **Minor units** (`minor_units`) — how many places a cash *amount* has: 2 for USD/EUR, **0 for JPY** (there are no yen cents), 3 for KWD/BHD.

Get both from `GET /v1/currencies` (`{"code": "JPY", "decimals": 3, "minor_units": 0}`) instead of hard-coding. Rounding rules:

- Round **once**, at the end of the calculation chain — never round the rate, then the product.
- Round amounts to the **target currency's** minor units (a USD→JPY conversion yields whole yen).
- Store amounts as integers in minor units or as decimals — never as binary floats. `0.1 + 0.2 != 0.3` bugs become real money here.
- Prefer the API's `converted` field (already minor-unit-rounded server-side) over multiplying `rate` client-side.

## 5. Derived crosses are triangulated — know when that matters

Most FX APIs quote only a few dozen pairs natively; everything else is triangulated (EUR/GBP = EUR/USD ÷ GBP/USD). exchangerate.dev makes this visible: `derived: true` / `derived_symbols`, with the error bound in `derivation_bps_max` (typically 1–2 bps).

- Display, pricing, dashboards: derived is fine — 1–2 bps is far inside the indicative-rate band.
- Research comparing your numbers against Bloomberg/Reuters native crosses: filter `derived_symbols` or expect small systematic drift.
- Doing your own triangulation? Divide **full-precision** rates and round once at the end; triangulating pre-rounded rates compounds the error.

## 6. "Current" is a claim — verify it before making it

A rate fetched on Sunday is not current, whatever your cache says. Check the session context before labeling:

- `market_session: open` → live market, safe to present as current.
- `market_session: weekend` → interbank closed; a `source: live` value is the **last trading-week consensus**. Display it, but label it ("last updated Fri 21:59 UTC").
- `market_session: interbank_closed` → the post-Friday-close / pre-Sydney-open gap.
- `source: ecb_daily` → a once-a-day fix that can be up to ~24 h old on a weekday and ~72 h old on Sunday — even though the API call succeeded seconds ago.

Cache accordingly: caching a live-session rate for 24 h is a staleness bug; re-fetching an ECB daily fix every minute is a rate-limit bug. Key cache TTL off `source`, and surface `data_updated_at` to users rather than "now".

## 7. Indicative vs settlement — don't let display rates touch money movement

Aggregated indicative rates (~5–15 bps band) are for display, analytics, and internal tools. The rate at which money actually moves comes from your payment provider/bank at execution time, including their spread. Never reconcile books against a display-rate conversion, and never promise a customer the indicative number as the transaction rate — quote it as an estimate. For rate-of-record conventions in invoicing and bookkeeping, use the `fx-accounting-rates` skill.

## Quick checklist

Before shipping FX-touching code, verify:

- [ ] Weekend/holiday dates handled explicitly (`is_forward_filled` checked, or absent range rows expected)
- [ ] Research pipelines join on exact dates, fill afterwards
- [ ] Timezone-of-record chosen and documented for any cross-series join
- [ ] Precision from `/v1/currencies`, no hard-coded `round(x, 4)`, no float money
- [ ] Derived pairs identified and acceptable for the use case
- [ ] UI labels weekend/stale rates instead of implying live
- [ ] Cache TTL varies by `source`; 429s back off via `x-ratelimit-reset`
- [ ] Nothing settles or reconciles against an indicative rate

More Debugging skills

← All Debugging skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY