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
  1. The universe: twelve symbols, one call shape
  2. Step 1: pull a year of daily bars
  3. Why the calendars are aligned before returns
  4. Step 2: draw the correlation matrix heatmap in Python
  5. Reading the stock, forex and commodity correlations
  6. Markets that close at different hours
  7. Step 3: rolling gold and dollar correlation
  8. Checks before you trust the matrix
  9. 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
Pearson correlation of daily log returns, 2025-09-30 to 2026-09-25.

The universe: twelve symbols, one call shape

SymbolRouteWhat it stands forAnnualised volatility
US500indicesUS 500 large-cap benchmark13.4%
DE40indicesGermany 4016.4%
JP225indicesJapan 22529.6%
US:JPMstocksA large US bank23.7%
US:XOMstocksA US oil major26.5%
USTBONDetfsUS long Treasury ETF9.7%
EURUSDforexEuro in dollars5.4%
USDJPYforexDollar in yen8.4%
USDXindicesUS Dollar Index5.3%
XAUUSDcommoditiesGold29.0%
WTIUSDcommoditiesWTI crude oil54.0%
BTCUSDcryptoBitcoin47.5%
Volatility is the standard deviation of the same 228 daily log returns, times the square root of 252.

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

correlation.pyPython
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())
Output
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.00

r(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.
n = 228 gives a band of about ±0.13, so JP225 vs EURUSD (0.03) says nothing, while gold vs USDX (-0.43) is well outside it.

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

  • BTCUSD365
  • WTIUSD359
  • XAUUSD357
  • EURUSD349
  • USDJPY347
  • DE40253
  • US500, US:JPM, US:XOM, USTBOND, USDX251
  • JP225244
  • Common to all twelve229
Row counts returned by the bars endpoint for 2025-09-26 to 2026-09-25. 229 common closes give 228 returns.TickerLayer daily bars, pulled 2026-09-28

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.
On this data the wrong order moved BTCUSD vs US500 from 0.50 to 0.47.

Step 2: draw the correlation matrix heatmap in Python

correlation.py (continued)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

US500DE40JP225US:JPMUS:XOMUSTBONDEURUSDUSDJPYUSDXXAUUSDWTIUSDBTCUSDUS5001.000.540.190.41-0.280.210.27-0.16-0.260.32-0.360.50DE400.541.000.330.29-0.340.200.13-0.09-0.160.27-0.390.28JP2250.190.331.000.14-0.13-0.050.030.13-0.020.21-0.060.09US:JPM0.410.290.141.00-0.040.070.12-0.11-0.150.16-0.200.23US:XOM-0.28-0.34-0.13-0.041.00-0.35-0.060.100.08-0.030.61-0.03USTBOND0.210.20-0.050.07-0.351.000.24-0.29-0.260.14-0.36-0.02EURUSD0.270.130.030.12-0.060.241.00-0.54-0.950.45-0.170.16USDJPY-0.16-0.090.13-0.110.10-0.29-0.541.000.69-0.180.14-0.01USDX-0.26-0.16-0.02-0.150.08-0.26-0.950.691.00-0.430.16-0.17XAUUSD0.320.270.210.16-0.030.140.45-0.18-0.431.00-0.040.32WTIUSD-0.36-0.39-0.06-0.200.61-0.36-0.170.140.16-0.041.00-0.10BTCUSD0.500.280.090.23-0.03-0.020.16-0.01-0.170.32-0.101.00
Pearson correlation of 228 aligned daily log returns. Computed, not illustrative.TickerLayer daily bars, 2025-09-26 to 2026-09-25, pulled 2026-09-28

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)

Japan 225Tokyo cash session
Germany 40Frankfurt cash sessionclose 15:30
US 500New York regular sessionclose 20:00
Bitcoindaily bar = UTC daybar ends 24:00

Hours in UTC

The Tokyo close comes seven hours before New York opens, so Japan reacts to the US move one date later.

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.

correlation.py (continued)Python
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}")
Output
JP225 vs US500, same day: 0.19; US500 one day earlier: 0.46

Step 3: rolling gold and dollar correlation

correlation.py (continued)Python
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}")
Output
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.56

Gold vs US Dollar Index, 60-day rolling correlation

correlation

Sampled every two weeks from the first full 60-day window. Computed from the same aligned returns as the heatmap.TickerLayer daily bars for XAUUSD and USDX, 2026

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() before diff(), 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.

Keep reading

Ready to integrate?

Start with the free tier, explore the docs, and connect via REST or WebSocket in minutes.