Tutorial
Correlation matrix in Python for stocks, FX, gold and crypto
The corr() call is one line. The three things that decide whether the number means anything come before it: returns instead of prices, aligned calendars, and knowing when each market closed.
On this page
- The universe: twelve symbols, one call shape
- Step 1: pull a year of daily bars
- Why the calendars are aligned before returns
- Step 2: draw the correlation matrix heatmap in Python
- Reading the stock, forex and commodity correlations
- Markets that close at different hours
- Step 3: rolling gold and dollar correlation
- Checks before you trust the matrix
- Questions
Key takeaways
- Correlate daily returns, never price levels: two trending price series look correlated whatever they are.
- Align every series on the days all markets traded before computing returns, or a crypto weekend gets compared with a stock Monday.
- Over 228 common trading days to 2026-09-25, gold had a -0.43 daily-return correlation with the US Dollar Index and EURUSD had -0.95.
- Markets that close at different hours look less related than they are: JP225 vs US500 was 0.19 same-day but 0.46 against the previous US session.
- A 60-day rolling gold vs dollar correlation ranged from -0.23 to -0.64 within the same year, so one full-window figure hides a lot.
A correlation matrix in Python is returns.corr() on a pandas DataFrame with one column of daily returns per instrument. Four steps get you there: pull daily bars, align them on the dates every market traded, convert closes to log returns, and correlate. This tutorial does it for twelve symbols, from stocks and currencies to gold, oil and bitcoin; the exchange rate API guide covers the currency side in depth.
Every number and chart below was computed from real daily bars pulled on 2026-09-28 for the window 2025-09-26 to 2026-09-25. Rerun the script on another day and the figures will move, which is part of the lesson.
- 12symbols, six asset classes
- 228common daily returns
- -0.43gold vs US Dollar Index
- 0.61WTI crude vs a US oil major
The universe: twelve symbols, one call shape
| Symbol | Route | What it stands for | Annualised volatility |
|---|---|---|---|
US500 | indices | US 500 large-cap benchmark | 13.4% |
DE40 | indices | Germany 40 | 16.4% |
JP225 | indices | Japan 225 | 29.6% |
US:JPM | stocks | A large US bank | 23.7% |
US:XOM | stocks | A US oil major | 26.5% |
USTBOND | etfs | US long Treasury ETF | 9.7% |
EURUSD | forex | Euro in dollars | 5.4% |
USDJPY | forex | Dollar in yen | 8.4% |
USDX | indices | US Dollar Index | 5.3% |
XAUUSD | commodities | Gold | 29.0% |
WTIUSD | commodities | WTI crude oil | 54.0% |
BTCUSD | crypto | Bitcoin | 47.5% |
Every one of them answers GET /{asset}/agg/{symbol}/1/day/{from}/{to} with the same bar shape (o, h, l, c, v, and t in Unix milliseconds), so the loader is one function. Stocks carry a market prefix (US:JPM), ETFs and indices use their plain codes, and crypto pairs have no separator. Indices, ETFs and commodities are separate feeds from stocks, FX and crypto, so check that your plan covers all six before you run it.
Step 1: pull a year of daily bars
import os
import time
import numpy as np
import pandas as pd
import requests # pip install requests pandas numpy matplotlib
BASE_URL = "https://api.tickerlayer.com"
START, END = "2025-09-26", "2026-09-25"
# symbol -> asset class, the first segment of the REST path
UNIVERSE = {
"US500": "indices", "DE40": "indices", "JP225": "indices",
"US:JPM": "stocks", "US:XOM": "stocks", "USTBOND": "etfs",
"EURUSD": "forex", "USDJPY": "forex", "USDX": "indices",
"XAUUSD": "commodities", "WTIUSD": "commodities", "BTCUSD": "crypto",
}
session = requests.Session()
session.headers["x-api-key"] = os.environ["TICKERLAYER_API_KEY"]
def daily_closes(symbol, asset, start=START, end=END):
"""Daily closes for one symbol, indexed by session date."""
url = f"{BASE_URL}/{asset}/agg/{symbol}/1/day/{start}/{end}"
params = {"sort": "asc", "limit": 5000, "offset": 0}
rows = []
for _ in range(20): # pages, with room for a few 429 retries
resp = session.get(url, params=params, timeout=20)
if resp.status_code == 429:
time.sleep(float(resp.headers.get("Retry-After", "1")))
continue
resp.raise_for_status()
body = resp.json()
rows.extend(body["results"])
if body.get("next_offset") is None:
break
params["offset"] = body["next_offset"]
if not rows:
raise ValueError(f"no daily bars for {symbol}")
dates = pd.to_datetime([bar["t"] for bar in rows], unit="ms") # 00:00 UTC of the session date
return pd.Series([bar["c"] for bar in rows], index=dates, name=symbol)
closes = pd.concat(
[daily_closes(symbol, asset) for symbol, asset in UNIVERSE.items()], axis=1
).sort_index()
closes.to_csv("closes.csv") # keep a copy: re-running costs one request per symbol
aligned = closes.dropna() # only days on which every market printed a bar
returns = np.log(aligned).diff().dropna()
corr = returns.corr()
print(f"{len(returns)} common daily returns, {returns.index[0]:%Y-%m-%d} to {returns.index[-1]:%Y-%m-%d}")
print(corr.round(2).to_string())228 common daily returns, 2025-09-30 to 2026-09-25
US500 DE40 JP225 US:JPM US:XOM USTBOND EURUSD USDJPY USDX XAUUSD WTIUSD BTCUSD
US500 1.00 0.54 0.19 0.41 -0.28 0.21 0.27 -0.16 -0.26 0.32 -0.36 0.50
DE40 0.54 1.00 0.33 0.29 -0.34 0.20 0.13 -0.09 -0.16 0.27 -0.39 0.28
JP225 0.19 0.33 1.00 0.14 -0.13 -0.05 0.03 0.13 -0.02 0.21 -0.06 0.09
US:JPM 0.41 0.29 0.14 1.00 -0.04 0.07 0.12 -0.11 -0.15 0.16 -0.20 0.23
US:XOM -0.28 -0.34 -0.13 -0.04 1.00 -0.35 -0.06 0.10 0.08 -0.03 0.61 -0.03
USTBOND 0.21 0.20 -0.05 0.07 -0.35 1.00 0.24 -0.29 -0.26 0.14 -0.36 -0.02
EURUSD 0.27 0.13 0.03 0.12 -0.06 0.24 1.00 -0.54 -0.95 0.45 -0.17 0.16
USDJPY -0.16 -0.09 0.13 -0.11 0.10 -0.29 -0.54 1.00 0.69 -0.18 0.14 -0.01
USDX -0.26 -0.16 -0.02 -0.15 0.08 -0.26 -0.95 0.69 1.00 -0.43 0.16 -0.17
XAUUSD 0.32 0.27 0.21 0.16 -0.03 0.14 0.45 -0.18 -0.43 1.00 -0.04 0.32
WTIUSD -0.36 -0.39 -0.06 -0.20 0.61 -0.36 -0.17 0.14 0.16 -0.04 1.00 -0.10
BTCUSD 0.50 0.28 0.09 0.23 -0.03 -0.02 0.16 -0.01 -0.17 0.32 -0.10 1.00r(t) = ln( close(t) / close(t-1) )ρ(x, y) = cov(x, y) / ( σ(x) × σ(y) )noise band ≈ ± 2 / √n
- r(t)
- Daily log return. Log returns add up over time and treat up and down moves symmetrically.
- ρ
- Pearson correlation, from -1 (mirror image) through 0 (unrelated) to +1 (lockstep).
- n
- Number of paired returns. Below the noise band, a coefficient is indistinguishable from zero.
Why the calendars are aligned before returns
Each market prints bars on its own calendar. Bitcoin trades every day, currencies and gold most days, stocks and indices on their own exchange's trading days, and holidays differ by country. Over the same twelve months the series came back with anywhere from 244 to 365 rows.
Daily bars per series in the same 12-month window
Align, then difference
closes.dropna()keeps only dates where every market has a close.- Monday's bitcoin return then runs from Friday to Monday, like every stock's.
- Every row compares the same span of calendar time.
Difference, then align
- Bitcoin's Monday return covers Sunday to Monday only.
- Stocks' Monday return covers the whole weekend.
- Correlations drift toward zero for reasons that are pure bookkeeping.
Step 2: draw the correlation matrix heatmap in Python
import matplotlib
matplotlib.use("Agg") # render to a file, no window needed
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(9, 7.5))
im = ax.imshow(corr.values, cmap="RdBu_r", vmin=-1, vmax=1)
ax.set_xticks(range(len(corr)), corr.columns, rotation=45, ha="right")
ax.set_yticks(range(len(corr)), corr.index)
for i in range(len(corr)):
for j in range(len(corr)):
ax.text(j, i, f"{corr.iat[i, j]:.2f}", ha="center", va="center", fontsize=8)
fig.colorbar(im, ax=ax, shrink=0.8, label="correlation of daily log returns")
ax.set_title(f"Daily return correlation, {returns.index[0]:%Y-%m-%d} to {returns.index[-1]:%Y-%m-%d}")
fig.tight_layout()
fig.savefig("correlation_heatmap.png", dpi=150)Daily return correlation, 2025-09-30 to 2026-09-25
Reading the stock, forex and commodity correlations
- The dollar, three waysEURUSD vs USDX is -0.95 and USDJPY vs USDX is 0.69. The dollar index and the euro are almost one trade in mirror image; the yen shares less of it.
- Gold against the dollarXAUUSD vs USDX is -0.43 and vs EURUSD +0.45. A weaker dollar lined up with firmer gold on average, but far from one for one.
- Oil and its producersUS:XOM vs WTIUSD is 0.61, the strongest pair outside FX. Oil also sat at -0.36 against both US500 and the long Treasury ETF over this year.
- Bitcoin as a risk assetBTCUSD vs US500 is 0.50, higher than US500 vs a large US bank (0.41). Over this window bitcoin moved with equities more than with gold (0.32).
Treat these as a description of one year, not a law. A stock correlation matrix built in a calm year and one built in a year of oil shocks will disagree, and so will a matrix built from weekly instead of daily returns. The gold price API guide has more on what moves gold, and the yield curve explainer covers why long Treasuries react to inflation scares.
Markets that close at different hours
Where each daily bar ends, in UTC (northern summer)
Hours in UTC
The same-day number for JP225 vs US500 was only 0.19. Shift the US series by one day and it jumps to 0.46: most of what happens in New York shows up in Tokyo the next morning, stamped with the next date. Frankfurt overlaps New York for two hours, so DE40 shows the opposite pattern, 0.54 same-day and 0.16 lagged. If your portfolio mixes Asia with the US, compute both before deciding two markets are unrelated.
same_day = returns["JP225"].corr(returns["US500"])
next_day = returns["JP225"].corr(returns["US500"].shift(1))
print(f"JP225 vs US500, same day: {same_day:.2f}; US500 one day earlier: {next_day:.2f}")JP225 vs US500, same day: 0.19; US500 one day earlier: 0.46Step 3: rolling gold and dollar correlation
window = 60 # trading days, about three months
gold_usd = returns["XAUUSD"].rolling(window).corr(returns["USDX"]).dropna()
print(gold_usd.resample("ME").last().round(2).to_string()) # "ME" = month end, pandas 2.2+
print(f"range: {gold_usd.min():.2f} to {gold_usd.max():.2f}, latest {gold_usd.iloc[-1]:.2f}")2026-01-31 -0.42
2026-02-28 -0.51
2026-03-31 -0.38
2026-04-30 -0.35
2026-05-31 -0.30
2026-06-30 -0.56
2026-07-31 -0.53
2026-08-31 -0.55
2026-09-30 -0.56
range: -0.64 to -0.23, latest -0.56Gold vs US Dollar Index, 60-day rolling correlation
correlation
The full-year figure of -0.43 sits in the middle of a range from -0.23 in early January to -0.64 at the start of July. The window length matters as much as the pair. On the same data a 20-day window swung from -0.87 all the way to +0.05, while a 120-day window stayed between -0.50 and -0.34. Short windows react fast and lie often; long ones are stable and slow. For a hedge ratio or a risk limit, use the longer window and watch the shorter one for regime breaks.
Checks before you trust the matrix
- Returns, not prices: correlating price levels of two trending series produces large numbers that mean nothing.
- Align calendars with
dropna()beforediff(), never after. - Compare the lagged correlation for markets that close hours apart.
- Inspect the largest daily moves per column; one bad print can move a coefficient. The bad ticks guide shows how to screen them.
- Ignore coefficients inside the ±2/√n noise band (about ±0.13 for a year of daily data).
- Check units before comparing levels: sugar and grains are quoted in US cents, gold in dollars.
- Pearson reacts to outliers.
returns.corr(method="spearman")is a rank-based cross-check.
The same bars feed other research: the historical stock data guide covers adjustments and official closes, and the quant research use case shows how teams use the history endpoints. Symbol pages such as USDX and XAUUSD show the latest delayed values.
Questions
How do you create a correlation matrix in Python?
Put one daily return series per column in a pandas DataFrame and call df.corr(). Align the series on common dates first and use returns rather than prices.
Should I use prices or returns for a correlation matrix?
Returns. Price levels of any two trending assets correlate strongly by accident; daily or weekly returns measure whether the moves happen together.
Is gold negatively correlated with the dollar?
Usually, but loosely. Over 228 trading days to 2026-09-25, gold's daily returns had a -0.43 correlation with the US Dollar Index, and the 60-day figure ranged from -0.23 to -0.64 within the year.
Why do Asian and US stock indices look uncorrelated?
Their daily bars close at different hours. Tokyo closes before New York opens, so the US move shows up in Japan on the next date; correlating against the previous US day reveals the link.
What window should I use for a rolling correlation?
Longer windows (60 to 120 days) are stable enough for sizing and hedging; 20-day windows are noisy but flag regime changes early. Compare both.