# Backtesting with Historical Rates

> Run a historical simulation on dated observations while controlling look-ahead bias, carry-forward values and uneven currency coverage.

Canonical: https://currency-core.com/docs/guides/backtesting/
Updated: 2026-11-12
Published: 2026-11-11

---
## The job

Replay a currency strategy against historical data without inventing information
that was not available at the decision date. Use
[`/v1/history`](/docs/history-endpoint/) for the daily series and
[`/v1/history/analysis`](/docs/history-analysis-endpoint/) for window
statistics.

`/v1/history` is available on every plan, with a 30-day window on Free and full
history on Starter and above. The analysis endpoint requires Growth or higher.

## Three biases to control

| Bias | What goes wrong | Control |
|---|---|---|
| Look-ahead | A signal uses a value published after the trade date | Use only observations at or before the decision date |
| Carry-forward | A repeated weekend value is counted as a new observation | Detect repeats and decide whether they are actionable |
| Survivorship | The current currency list is treated as the universe that existed in the past | Check each currency's first quote and document the universe |

A backtest that ignores these three will usually look better than the strategy
actually was.

## Look-ahead bias

At each simulated decision date, use only values that were available on or
before that date. Do not join a signal computed from a later revision onto an
earlier trade.

Build the series once, then slice it by date rather than querying a full range
and reading forward. If your signal needs a 30-day lookback, the first decision
date is the 31st observation, not the first row.

## Carry-forward is not a new observation

History returns a dense date map. Weekends and holidays repeat the last published
trading day, so a value can be unchanged for several calendar days. That is a
repeat, not a fresh fixing. A genuinely unchanged fixing looks identical, so the
API does not label the difference.

If your strategy trades on "new" data, detect repeats and skip them. If your
strategy trades on calendar time, keep them and document that choice.

## A complete signal example

This example fetches a series, marks repeats, and computes a simple 30-day
momentum signal. It is a signal demonstration, not a profit-and-loss model. Add
execution, spreads, financing and position sizing separately.

```ts
type HistoryResponse = {
  currency: string;
  base: string;
  interval: string;
  from: string;
  to: string;
  rates: Record<string, number>;
};

type Observation = {
  date: string;
  rate: number;
  repeat: boolean;
};

type Signal = {
  date: string;
  rate: number;
  lookbackRate: number;
  changePct: number;
  signal: "long" | "short";
};

async function fetchHistory(
  currency: string,
  base: string,
  from: string,
  to: string,
): Promise<HistoryResponse> {
  const url = new URL("https://api.currency-core.com/v1/history");
  url.searchParams.set("currency", currency);
  url.searchParams.set("base", base);
  url.searchParams.set("from", from);
  url.searchParams.set("to", to);
  url.searchParams.set("interval", "daily");

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.CURRENCECORE_API_KEY}` },
    signal: AbortSignal.timeout(30_000),
  });
  const body = await res.json();

  if (!res.ok) {
    throw Object.assign(new Error(body.error?.message ?? `HTTP ${res.status}`), {
      status: res.status,
      code: body.error?.code,
    });
  }
  return body as HistoryResponse;
}

function toObservations(data: HistoryResponse): Observation[] {
  let previous: number | undefined;
  return Object.entries(data.rates).map(([date, rate]) => {
    const repeat = previous !== undefined && rate === previous;
    previous = rate;
    return { date, rate, repeat };
  });
}

export async function momentumBacktest(input: {
  currency: string;
  base: string;
  from: string;
  to: string;
  lookback?: number;
}) {
  const lookback = input.lookback ?? 30;
  const data = await fetchHistory(input.currency, input.base, input.from, input.to);
  const observations = toObservations(data);
  const signals: Signal[] = [];

  for (let i = lookback; i < observations.length; i += 1) {
    const current = observations[i];
    const past = observations[i - lookback];

    // Act only on a changed value, not on a weekend repeat.
    if (current.repeat || !past) continue;

    const change = (current.rate - past.rate) / past.rate;
    signals.push({
      date: current.date,
      rate: current.rate,
      lookbackRate: past.rate,
      changePct: change * 100,
      signal: change > 0 ? "long" : "short",
    });
  }

  return {
    observations: observations.length,
    actionable: signals.length,
    signals,
  };
}
```

Call it once per pair:

```ts
const result = await momentumBacktest({
  currency: "EUR",
  base: "USD",
  from: "2015-01-01",
  to: "2025-12-31",
  lookback: 30,
});

console.log(result.observations, result.actionable, result.signals[0]);
```

A signal row looks like:

```json
{
  "date": "2026-01-30",
  "rate": 1.0861,
  "lookbackRate": 1.0352,
  "changePct": 4.92,
  "signal": "long"
}
```

The numbers are illustrative. The important part is that `date` is the decision
date, `lookbackRate` was available on or before that date, and a repeat row was
not treated as a new observation.

## Coverage and universe

History coverage is uneven:

- GBP/USD and USD/INR annual parity starts in 1949.
- Daily major pairs start in 1971.
- EUR pairs start in 1999.
- Some Asian and emerging-market pairs start later.

Dates before a currency's first quote are absent, not zero. Do not treat the
current currency list as a survivorship-bias-free universe, and do not assume
every pair has the same start date. Run the backtest per pair and compare only
over the intersection of the available windows.

## Plan and request limits

- Free: last 30 days of history. Older `from` dates return `403 forbidden`.
- Starter and above: full history.
- `/v1/history/analysis`: Growth or higher.
- No maximum span on `/v1/history`, so a single request can return a multi-decade
  series. Chunk for memory and retries, not because of an API limit.
- The per-minute limit counts every request, including retries.

## Worked example

Backtest a 30-day momentum signal on EUR/USD from 2015-01-01 to 2025-12-31:

1. Fetch the daily series once.
2. Convert the date map to ordered observations and mark repeats.
3. Start the loop at index 30 so every signal has a full lookback.
4. Skip repeat rows so a weekend does not create a trade.
5. Record the signal date, the current rate, the lookback rate and the change.
6. Add execution costs and position sizing in a separate layer.

Do not compute the signal from the last row and then apply it to earlier rows.
That is look-ahead bias.

## Failure handling

| Status | What to do |
|---|---|
| `400 invalid_input` | Check the currency, base, date format and range |
| `401 unauthorized` | Check the server-side key |
| `403 forbidden` | A Free key asked for a range outside the 30-day window |
| `404 unknown_currency` | The currency has no history in the window |
| `429 rate_limited` | Honor `Retry-After`; retry the failed chunk |
| `5xx` | Retry with backoff and keep completed chunks |

## Next steps

- [History endpoint](/docs/history-endpoint/)
- [History analysis](/docs/history-analysis-endpoint/)
- [History coverage](/docs/guides/history-coverage/)
- [Data quality](/docs/guides/data-quality/)
- [Rate limits](/docs/rate-limits/)
- [All developer guides](/docs/guides/)
