Historical exchange rates in Python: a 30-day series with pandas
A month of daily exchange rates is the raw material for a lot of everyday work: revaluing last month's invoices, charting a currency against your costs, checking a finance export, or feeding a model. This tutorial builds that series in Python with one API call per day, cached on disk, paced to your plan's rate limit, and loaded into a pandas DataFrame ready for analysis.
Which plan this needs
The straight answer first: the /historical endpoint requires the Pro plan or higher. On the Free plan it answers with HTTP 403 and the error type feature_not_available. Pro includes 40,000 requests a month and 100 requests a minute. A 30-day series costs 30 requests the first time and about one a day after that. Plans and prices are on the pricing page.
On the Free plan you can still build a history, just not backwards: /latest works on every plan and every response carries its date, so a daily job that stores one snapshot gives you your own series from today on.
What you need
- Python 3.10 or newer, with
pip install pandas requests. - A Rate-API key on Pro or higher. Sign up, upgrade from the dashboard, then
export RATE_API_KEY="your-key-here".
One call per day
/historical returns one day's rates for a base currency:
curl -H "X-API-Key: $RATE_API_KEY" \
"https://rate-api.com/api/v1/historical?date=2026-09-01&base=USD&symbols=EUR,GBP,JPY"
The shape of the response (values elided):
{
"success": true,
"historical": true,
"date": "2026-09-01",
"base": "USD",
"rates": { "EUR": …, "GBP": …, "JPY": … },
"timestamp": 1790121600
}
dateandbaseecho your request, andhistoricalis alwaystrue.ratesholds units of each currency per 1 unit of the base. As with/latest, the base itself is not in the map; if you want a USD column of 1.0s, add it yourself.symbolsis optional but worth using. One call returns every currency you list, so a series of three currencies costs the same as a series of one.
Two details shape the code. If no rates are stored for a date, the API returns 404 with the error type no_data_for_date instead of an empty success, so a gap can never be mistaken for a zero. And there is one set of rates per date: these are daily reference rates, not tradable quotes or intraday ticks, and a same-day refresh replaces that day's values. That is why today's date is never cached below.
The script
"""Daily exchange rates for the last 30 days, as a pandas DataFrame."""
import json
import os
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pandas as pd
import requests
API = "https://rate-api.com/api/v1/historical"
BASE = "USD"
SYMBOLS = ["EUR", "GBP", "JPY"]
CACHE_DIR = Path(".rate-cache")
session = requests.Session()
session.headers["X-API-Key"] = os.environ["RATE_API_KEY"]
class RateApiError(Exception):
pass
def fetch_day(day, retries=3):
"""One /historical call: the rates dict, or None when the API has no data for that day."""
params = {"date": day.isoformat(), "base": BASE, "symbols": ",".join(SYMBOLS)}
for attempt in range(retries + 1):
res = session.get(API, params=params, timeout=10)
try:
body = res.json()
except ValueError: # e.g. an HTML error page from a proxy
body = {}
error = body.get("error") or {}
if res.status_code == 200 and body.get("success"):
# Spend the per-minute budget evenly instead of running into a 429.
if res.headers.get("X-RateLimit-Remaining") == "0":
reset = int(res.headers.get("X-RateLimit-Reset", "0"))
time.sleep(max(0, reset - time.time()) + 1)
return body["rates"]
if res.status_code == 404 and error.get("type") == "no_data_for_date":
return None
if error.get("type") == "rate_limit_exceeded" and attempt < retries:
time.sleep(int(res.headers.get("Retry-After", "60")) + 1)
continue
raise RateApiError(f"{res.status_code} {error.get('type')}: {error.get('message')}")
def get_day(day):
"""Completed days are settled, so cache them; today's rates may still be refreshed."""
path = CACHE_DIR / f"{BASE}-{'-'.join(SYMBOLS)}-{day.isoformat()}.json"
if path.exists():
return json.loads(path.read_text())
rates = fetch_day(day)
if rates is not None and day < datetime.now(timezone.utc).date():
CACHE_DIR.mkdir(exist_ok=True)
path.write_text(json.dumps(rates))
return rates
def build_series(days=30):
end = datetime.now(timezone.utc).date() - timedelta(days=1) # last complete UTC day
dates = [end - timedelta(days=n) for n in range(days - 1, -1, -1)]
rows = {}
for day in dates:
rates = get_day(day)
if rates is not None:
rows[pd.Timestamp(day)] = rates
df = pd.DataFrame.from_dict(rows, orient="index").sort_index()
df = df.reindex(pd.date_range(dates[0], dates[-1], freq="D")) # gaps become NaN rows
df.index.name = "date"
return df
if __name__ == "__main__":
df = build_series()
gaps = df.index[df.isna().any(axis=1)]
if len(gaps):
print("No data for:", ", ".join(d.strftime("%Y-%m-%d") for d in gaps))
filled = df.ffill().dropna() # carry the last known rate over a gap, and say so
summary = pd.DataFrame({
"first": filled.iloc[0],
"last": filled.iloc[-1],
"change_%": (filled.iloc[-1] / filled.iloc[0] - 1) * 100,
"low": filled.min(),
"high": filled.max(),
"daily_std_%": filled.pct_change().std() * 100,
})
print(summary.round(4))
# Every column is quoted against USD, so a cross rate is one division.
eur_gbp = filled["GBP"] / filled["EUR"]
print(eur_gbp.rolling(7).mean().dropna().round(5).tail())
filled.to_csv("usd-rates-30d.csv")
Run it with python history.py. It prints any dates the API had no data for, a summary table and a 7-day rolling EUR/GBP rate, and writes usd-rates-30d.csv.
How it works
Retries that respect the rate limit
fetch_day() handles the outcomes that matter. A 200 returns the rates. A 404 with no_data_for_date returns None: a gap, not a failure. A 429 with rate_limit_exceeded sleeps for the number of seconds in the Retry-After header and tries again, up to three times. Anything else raises with the API's error type and message, because retrying can't fix a revoked key (401), the Free-plan 403 or a malformed date (400).
It also avoids the 429 in the first place. Every authenticated response carries X-RateLimit-Remaining and X-RateLimit-Reset, a Unix timestamp. When the remaining budget reaches zero, the script sleeps until the window resets instead of sending a request it knows will be refused. Pro allows 100 requests a minute, shared by every key on the account, so 30 sequential calls never get close, but the same function stays well-behaved when you loop over a dozen base currencies or run several jobs at once.
One 429 is different. When the monthly quota is used up, the error type is quota_exceeded, and nothing changes until the quota resets at the start of next month. The script only retries rate_limit_exceeded, so it fails fast instead of waiting on a limit that won't lift.
Caching past days forever
A completed day's rates are settled, so get_day() writes each completed day to .rate-cache/ and never asks for it again. The first run makes 30 requests; a daily run after that makes one, plus a retry for any day that came back empty. The series ends at yesterday in UTC, the last complete day. The cache file name includes the base and the symbol list, so changing either starts a fresh cache instead of mixing tables.
Building the DataFrame
build_series() turns the per-day dicts into a DataFrame with one row per date and one column per currency, then reindexes it against a complete daily calendar. That step matters: without it, a missing day silently disappears and a "30-day" change is computed over 29 rows. With it, gaps are explicit NaN rows you can report.
Analysing the series
The __main__ block is a typical first pass:
- Gaps are reported, then forward-filled.
ffill()carries the last known rate across a missing day, a common convention for daily FX series. Say so in any report built on it. - A summary table with the first and last value, the percentage change, the low, the high and the standard deviation of daily percentage changes, a simple volatility measure.
- Cross rates at no extra cost. Every column is quoted against USD, so EUR→GBP is
GBP / EURon the same row. No extra requests. - A 7-day rolling mean to smooth out daily noise.
- A CSV export for spreadsheets and BI tools.
To chart currencies whose rates differ in magnitude, such as JPY and EUR against USD, rebase each column to 100 first. With matplotlib installed, append this to the __main__ block:
rebased = filled / filled.iloc[0] * 100
ax = rebased.plot(title="USD exchange rates, rebased to 100")
ax.figure.savefig("usd-rates-30d.png", dpi=150)
Variations
- A different base. Set
BASE = "EUR"andSYMBOLS = ["USD", "GBP", "JPY"]for rates per euro; the API computes the cross rates for you. The base is left out of the map as always, so EUR can't stay in the symbol list, and the EUR/GBP line becomes plainfilled["GBP"]. - A longer window.
build_series(90)works the same way. At one request per day of data, a year is 365 requests: small next to Pro's monthly quota, and a few minutes of runtime the first time at 100 requests a minute. - Month-end rates for accounting.
filled.resample("ME").last()keeps the last row of each month. On pandas older than 2.2, use"M"instead of"ME". - Other formats. Add
format=csvto getcurrency,raterows instead of JSON. JSON keeps the error envelope easy to read, which is why the script sticks with it.
Summary
A 30-day series is 30 small requests: /historical with a date, an explicit base and the symbols you need. Cache past days forever, retry only rate_limit_exceeded and only after Retry-After, and reindex against a full calendar so gaps can't hide. Parameters and response fields for every endpoint are in the API reference, and for a quick look without code, the converter pages such as USD to EUR show the latest rate. Create an account and upgrade to Pro to run the script above.