# Backtest local-currency gold without FX look-ahead bias

A gold strategy can look different in EUR, JPY, IDR, INR, or TRY even when the underlying trade is the same. A USD gold series answers, "what did gold do in dollars?" A local-currency series answers, "what would this position have been worth to this investor?"

That distinction is simple arithmetic, but historical data can make it easy to get wrong. This guide combines settled daily XAU/USD bars from goldprice.dev with dated USD FX observations from exchangerate.dev. The important rule is that each FX value must be the value published for the simulated date, never a rate fetched today and placed in a historical row.

## The local-currency price series

For a currency quoted as local currency per USD, calculate the gold close in that currency as:

```
local_gold_close = gold_usd_close × usd_to_local_fx
```

For example, `XAU/USD × USD/IDR` produces the rupiah value of one troy ounce of gold. The same approach applies to any supported local currency.

This is a conversion identity, not an explanation of why either market moved. Keep the two source series and their observation dates alongside the derived local price so that the result is reproducible.

## Pull settled gold bars

Use the daily bars endpoint, not a current spot request. Bars are returned newest first, prices are decimal strings, and the currently forming bar can have `is_closed: false`. A backtest should use settled rows only.

```bash
curl -G "https://api.goldprice.dev/v1/bars" \
  -H "Authorization: Bearer $GOLDPRICE_API_KEY" \
  --data-urlencode "symbol=XAU-USD-SPOT" \
  --data-urlencode "interval=1d" \
  --data-urlencode "from=2025-07-10T00:00:00Z" \
  --data-urlencode "to=2026-07-10T00:00:00Z" \
  --data-urlencode "limit=400"
```

The response uses `bar_start` for the UTC start of each daily bar. Retain `close` and `is_closed`. Your plan's daily-history window still applies, and a longer range can require paging through `next_cursor`.

This exact one-year request uses Pro history. Free keys can request 30 days of daily history; use a shorter window or a plan with the required history depth before running it.

For a USD-only strategy, the existing [OHLC backtesting guide](/blog/backtest-gold-strategy-ohlc) is the simpler starting point. The remainder of this guide adds a second time series because the reporting currency itself moves.

## A validated one-year check

The method is not just theoretical. On the shared, settled business dates `2025-07-10` and `2026-07-10`, the XAU/USD daily close rose from `3323.81` to `4119.172`: a **23.93%** USD-gold return. The same position had different local-currency returns once the USD/local FX move was included:

| Reporting currency | Local-currency gold return |
| --- | ---: |
| EUR | 26.95% |
| JPY | 37.10% |
| IDR | 37.97% |
| INR | 37.76% |
| TRY | 45.50% |

Each FX endpoint response was an exact-date, non-forward-filled `ecb_daily` observation. The calculation is a historical reconciliation, not a forecast, causal claim, or investment recommendation. For the exact gold, FX, and interaction decomposition behind these figures, see [Was gold up, or was your currency down?](https://exchangerate.dev/learn/gold-local-currency-return-decomposition).

## Pull the FX series as it was known on each date

For multi-day work, exchangerate.dev's range endpoint returns the daily observations in one request. Here the base is USD and the requested currency is IDR, so each rate is rupiah per dollar.

```bash
curl -G "https://api.exchangerate.dev/v1/range" \
  -H "Authorization: Bearer $EXCHANGERATE_API_KEY" \
  --data-urlencode "base=USD" \
  --data-urlencode "symbols=IDR" \
  --data-urlencode "start_date=2025-07-10" \
  --data-urlencode "end_date=2026-07-10"
```

The range response contains published business-day rows rather than manufacturing a calendar row for every weekend or holiday. Do not fill those missing calendar dates with a later observation when building the historical series.

Read [historical FX time series in one call](https://exchangerate.dev/learn/historical-fx-time-series) for the range response and [how to backfill FX without look-ahead bias](https://exchangerate.dev/learn/backfill-without-lookahead) for the dated-observation rule.

## Build an aligned local-currency series in Python

This example requests both series, keeps settled gold bars and non-forward-filled FX rows, then performs an inner join on the date. An inner join is intentional: a date missing from either source is not silently invented.

```python
import os
from decimal import Decimal

import pandas as pd
import requests

GOLD_API = "https://api.goldprice.dev/v1"
FX_API = "https://api.exchangerate.dev/v1"

START = "2025-07-10"
END = "2026-07-10"
LOCAL_CURRENCY = "IDR"

# This one-year example uses Pro history. Free keys can request 30 days.


def fetch_gold_daily(start: str, end: str) -> pd.DataFrame:
    response = requests.get(
        f"{GOLD_API}/bars",
        headers={"Authorization": f"Bearer {os.environ['GOLDPRICE_API_KEY']}"},
        params={
            "symbol": "XAU-USD-SPOT",
            "interval": "1d",
            "from": f"{start}T00:00:00Z",
            "to": f"{end}T00:00:00Z",
            "limit": 400,
        },
        timeout=15,
    )
    response.raise_for_status()
    rows = response.json()["bars"]

    gold = pd.DataFrame(rows)
    gold = gold[gold["is_closed"] & gold["close"].notna()].copy()
    gold["date"] = pd.to_datetime(gold["bar_start"], utc=True).dt.date
    gold["gold_usd_close"] = gold["close"].map(Decimal)
    return gold.set_index("date")[["gold_usd_close"]].sort_index()


def fetch_fx_daily(start: str, end: str, currency: str) -> pd.DataFrame:
    response = requests.get(
        f"{FX_API}/range",
        headers={"Authorization": f"Bearer {os.environ['EXCHANGERATE_API_KEY']}"},
        params={
            "base": "USD",
            "symbols": currency,
            "start_date": start,
            "end_date": end,
        },
        timeout=15,
    )
    response.raise_for_status()
    # The range endpoint contains published business-day rows only. Keep its
    # missing weekends and holidays missing rather than carrying a later rate.
    fx = pd.DataFrame(response.json()["data"])
    fx["date"] = pd.to_datetime(fx["date"]).dt.date
    fx["usd_to_local"] = fx["rates"].map(lambda rates: Decimal(str(rates[currency])))
    return fx.set_index("date")[["usd_to_local"]].sort_index()


gold = fetch_gold_daily(START, END)
fx = fetch_fx_daily(START, END, LOCAL_CURRENCY)

# Only use dates with an actual settled gold close and a fresh FX observation.
series = gold.join(fx, how="inner")
series["local_gold_close"] = series["gold_usd_close"] * series["usd_to_local"]
series["local_return"] = series["local_gold_close"].pct_change()

print(series.tail())
```

The use of `Decimal` preserves the decimal-string values returned by the gold bars endpoint. Convert only at the presentation edge if a charting library needs floats.

## Avoid three common mistakes

### 1. Using today's rate in a historical row

`/v1/latest` is for a current value. It cannot reconstruct a rate that was knowable on a past day. Use the dated or range FX endpoints for historical work.

### 2. Treating a carried rate as a fresh observation

An FX value can be valid for display on a holiday while still not being newly published that day. The `is_forward_filled` field makes that distinction visible. Choose and document whether your model carries those values or restricts itself to jointly published dates. This example chooses the latter.

### 3. Acting on the same close that produced a signal

If a moving average uses a day's close, that close was not available before the day ended. Shift the trading signal by one row before applying it to returns.

```python
series["fast_ma"] = series["local_gold_close"].rolling(50).mean()
series["slow_ma"] = series["local_gold_close"].rolling(200).mean()
series["signal"] = (series["fast_ma"] > series["slow_ma"]).astype(int)

# A signal formed from today's close may only affect the next row's return.
series["strategy_return"] = series["local_return"] * series["signal"].shift(1)
series["strategy_value"] = (1 + series["strategy_return"].fillna(0)).cumprod()
```

That shift removes a separate source of look-ahead bias. It does not make the strategy profitable, and it does not account for spreads, taxes, storage, execution, or any local-market premium.

## Record the inputs with the result

For every run, save the date window, currency, API response timestamps, `is_closed` status, FX `is_forward_filled` status, and the exact code revision that generated the series. Those fields are what let a later reader distinguish a reproducible historical calculation from a current conversion pasted into an old spreadsheet.

For a live conversion or a consumer-facing local-currency price, use the [gold conversion endpoint](/docs/api-reference) instead. Historical conversion is a two-series calculation by design: use settled historical bars and the dated FX observations that belong to them.
