EUR/USD and GBP/USD can look like separate ideas while carrying similar exposure to the dollar. A forex correlation matrix helps you measure how their returns moved together over a chosen window. Get your free API key to run the Python example below with SiftingIO's Forex API, using EURUSD, GBPUSD and USDJPY.
The short answer: fetch historical daily closes, calculate log returns, align the observations, then apply Pearson correlation. Compare returns rather than raw price levels, and keep missing data visible.
What a forex correlation matrix tells you#
Each cell measures the linear relationship between two return series in your sample:
| Coefficient | Meaning |
|---|---|
| Close to +1 | Returns tended to move together. |
| Close to -1 | Returns tended to move in opposite directions. |
| Close to 0 | Little linear relationship in this sample, not proof of independence. |
A matrix describes a period, a sampling convention and a set of pairs. It is not a permanent property of those currencies or a forecast. Two high coefficients do not, by themselves, establish a profitable strategy or a safe hedge.
For a research dashboard or an AI assistant, store the sample dates, observation count and method alongside the numbers. “EURUSD/GBPUSD correlation” without that context is an incomplete answer.
Get historical Forex data for the calculation#
SiftingIO's Forex API gives you historical OHLCV through REST. This calculation only needs two fields: t, the bar-open timestamp in Unix milliseconds, and c, the closing price. The same response structure works for all three pairs, so you can reuse one reader.
The verified endpoint is GET /v1/hist/forex/{pair}/bars. It returns bars under data and pagination information under meta. Historical requests require Accept-Encoding: gzip. The Forex historical bars reference documents the parameters and response.
The example requests 180 calendar days of daily bars, excluding the current UTC day. With a limit of 200 bars per pair, that bounded window should fit on one page. It still checks meta.next_cursor and stops if more data remains. Expanding the date range requires pagination, not silently accepting the first page.
Build the correlation matrix in Python#
Install the two dependencies:
python -m pip install pandas numpy
Set SIFTING_API_KEY in your local environment or secret manager, then save the following as forex_correlation.py. Keep the key out of source control and browser-side code.
import gzip
import json
import os
from urllib.parse import urlencode
from urllib.request import Request, urlopen
import numpy as np
import pandas as pd
PAIRS = ("EURUSD", "GBPUSD", "USDJPY")
def daily_closes(pair, key, start, today):
params = urlencode({
"start": start.isoformat(),
"end": (today - pd.Timedelta(milliseconds=1)).isoformat(),
"interval": "1d", "order": "asc", "limit": 200,
})
request = Request(
f"https://api.sifting.io/v1/hist/forex/{pair}/bars?{params}",
headers={"X-API-Key": key, "Accept-Encoding": "gzip"},
)
with urlopen(request, timeout=30) as response:
raw = response.read()
if response.headers.get("Content-Encoding", "").lower() == "gzip":
raw = gzip.decompress(raw)
payload = json.loads(raw)
if payload.get("meta", {}).get("next_cursor"):
raise ValueError(f"{pair}: more pages exist; do not use a partial sample")
rows = pd.DataFrame(payload["data"])
if rows.empty:
raise ValueError(f"{pair}: no bars in this window")
times = pd.to_datetime(rows["t"], unit="ms", utc=True)
prices = pd.to_numeric(rows["c"], errors="raise").to_numpy(dtype=float)
series = pd.Series(prices, index=times, name=pair).sort_index()
if series.index.has_duplicates or not (series.index == series.index.normalize()).all():
raise ValueError(f"{pair}: duplicate or non-midnight daily timestamps")
if not np.isfinite(series).all() or (series <= 0).any():
raise ValueError(f"{pair}: closes must be finite and positive")
return series.loc[(series.index >= start) & (series.index < today)]
def correlation_sample(series_list):
# Put absent UTC dates back into the index as NaN, not invented prices.
closes = pd.concat(series_list, axis=1).sort_index().asfreq("D")
# Difference BEFORE dropping rows: a missing day invalidates both returns.
returns = np.log(closes).diff().dropna(how="any").tail(60)
if len(returns) < 30:
raise ValueError("Fewer than 30 shared return observations; inspect the data")
if (returns.std() == 0).any():
raise ValueError("Correlation is undefined for a constant return series")
return returns, returns.corr(method="pearson")
if __name__ == "__main__":
key = os.environ["SIFTING_API_KEY"]
today = pd.Timestamp.now(tz="UTC").normalize()
start = today - pd.Timedelta(days=180)
series = [daily_closes(pair, key, start, today) for pair in PAIRS]
sample, matrix = correlation_sample(series)
print(f"Shared return observations: {len(sample)}")
print(f"Included bar-open dates (UTC): {sample.index[0].date()} to {sample.index[-1].date()}")
print(matrix.round(3).to_string())
Run python forex_correlation.py. It prints the number of shared return observations, the first and last included bar-open dates and a three-by-three matrix. The coefficients will depend on the data returned when you run it; there is no fixed “correct” EURUSD/GBPUSD correlation to copy.
The script uses Python's standard HTTP client and explicitly decompresses gzip. HTTP errors stop the calculation rather than producing a matrix from an error response. If it reports insufficient data, inspect the returned window and your access before changing the guardrail.
Why these details change the result#
Use returns, not price levels#
The code calculates log(close_today) - log(close_previous_day). This measures proportional change. Pearson correlation is already scale-invariant, so the different quote magnitudes are not the issue: comparing levels answers a different question from comparing changes.
Raw price series can trend together and produce a striking coefficient that does not describe how their day-to-day changes co-moved. Returns answer that narrower question.
Align first, but do not fill missing prices#
The daily UTC grid deliberately preserves absent dates. The script differences prices before removing incomplete rows. If Tuesday is missing for one pair, neither its Tuesday return nor its Wednesday return enters the matrix. It never turns Monday-to-Wednesday movement into a one-day observation.
This is a conservative consecutive-UTC-bucket sample. It excludes returns crossing an absent daily bucket, including weekend closures, rather than treating the next available close as the next day. A returned bar can still cover a shortened trading period. This is not a New York-close trading-day sample, and calendar alignment alone does not prove that every underlying observation is complete.
If your analysis needs Friday-to-Monday returns, define a shared trading calendar and matching start/end observations for every pair. Do not just forward-fill the weekend. Investigate unexplained gaps by re-requesting the exact window through the historical bars endpoint; a missing observation is not evidence of a zero return.
Use the same observations in every cell#
By default, pandas correlation uses pairwise complete observations. Different cells can therefore use different dates when columns contain missing values.
Here, dropna(how="any") removes an observation from all three pairs before calculating the matrix. Every coefficient uses the same rows. The 60-row window means 60 retained return observations, not 60 calendar days. The minimum of 30 is a demonstration guardrail, not a claim of statistical reliability.
Watch which side of the pair contains USD#
EURUSD quotes dollars per euro. USDJPY quotes yen per dollar. The direction matters when interpreting the sign.
For a synthetic check, let Q = 1 / P. Then log(Q_t / Q_previous) = -log(P_t / P_previous). Reciprocating a price series flips its log returns and therefore flips its correlation with any other nonconstant series.
That identity applies to the exact reciprocal of the same observations. Separately sourced inverse quotes need not match perfectly. If you transform USDJPY into JPYUSD, transform the prices first and label the derived series explicitly; changing the column name alone is wrong.
Make the result useful in your application#
Show the observation count and latest data date beside the matrix. Keep the pair direction visible. A research notebook, customer-facing dashboard or AI tool should not present a stale matrix as a live measurement.
To examine stability, repeat the calculation with several explicitly labeled windows. Do not choose whichever window produces the strongest relationship. If you automate this job, cache the bars, fetch updates and retain the exact inputs used for each result.
The practical advantage of using SiftingIO here is straightforward: one authenticated REST interface and a consistent bar schema for the pairs you are comparing. You can inspect the input data instead of relying on an unexplained correlation number.
FAQ#
Do I need WebSocket for a forex correlation matrix?#
No. Historical OHLCV comes through REST. SiftingIO's WebSocket connection streams live prices, not OHLCV bars. This example does not open a WebSocket connection.
Can I use hourly returns instead?#
Yes, but change the request interval, timestamp validation, alignment grid and sample definition together. Retrieve all pages required for the window. Changing only interval="1h" in this daily example is not sufficient.
Is a high correlation a reason to trade two pairs together?#
Not on its own. Correlation measures historical co-movement, not expected return, causation or execution cost. This tutorial is a data-analysis example, not a trading recommendation.
Run it with your pairs#
Get your free API key, run the example, and inspect the sample before adding more pairs. The Forex API overview explains the available data; the historical endpoint documentation gives you the exact request contract.



