Correlation heatmap

q.plot.correlation_heatmap summarizes an entire feature library in a single panel. A scatterplot matrix shows the shape of each relationship but stops being readable after a handful of features; a heatmap keeps working at dozens of features because every pair collapses to one colored cell.

The default RdYlGn color scale is pinned to [-1, 1] and centered at zero, so panels stay comparable across feature sets and no relationship looks stronger simply because the rest of the matrix is quiet.

This page uses qrt’s bundled AAPL, SPY, and BTC-USD histories, so it executes without a network connection.

Build a wide feature library

Research pipelines rarely produce four tidy features. They produce families: the same measurement at several lookbacks, plus trend, volatility, volume, and relative-strength variants. The set below is built entirely from q.indicator and is deliberately redundant, which is exactly the condition a correlation audit is meant to expose.

import pandas as pd

import qrt as q

aapl = q.data.datasets.load("aapl")
spy = q.data.datasets.load("spy")
close, high, low, volume = aapl["close"], aapl["high"], aapl["low"], aapl["volume"]

features = pd.DataFrame(index=aapl.index)
for window in (1, 5, 10, 20, 60, 120):
    features[f"mom_{window}d"] = q.indicator.momentum(close, window=window)
for window in (10, 20, 50, 100, 200):
    features[f"sma_dist_{window}d"] = close.div(q.indicator.sma(close, window)).sub(1)
for window in (5, 10, 20, 60):
    features[f"vol_{window}d"] = q.indicator.rolling_volatility(close, window=window)
for window in (20, 60):
    features[f"madev_{window}d"] = q.indicator.madev(close, window=window)
for window in (5, 20, 60):
    features[f"volume_ratio_{window}d"] = q.indicator.volume_ratio(volume, window=window)
for window in (10, 60):
    features[f"range_{window}d"] = high.sub(low).div(close).rolling(window).mean()
for lookback in (21, 63):
    features[f"rs_spy_{lookback}d"] = q.indicator.relative_strength(close, spy["close"], lookback=lookback)
features["drawdown_252d"] = close.div(close.rolling(252).max()).sub(1)

features = features.dropna()
features.shape
(6427, 25)

Cluster the matrix

cluster=True reorders rows and columns by hierarchical clustering, so features that behave alike become adjacent and redundancy shows up as solid blocks along the diagonal. Values are hidden automatically once a matrix grows past the point where text stays readable; hover any cell for the exact number.

The blocks below are the point of the exercise: the volatility measures (vol_*, madev_*) collapse into one nearly interchangeable group, the trend and momentum family forms a second, and volume ratios form a third that is largely independent of price direction.

fig = q.plot.correlation_heatmap(
    features,
    cluster=True,
    title="AAPL feature library, clustered by similarity",
)
fig.show()

Compare against source order

The default keeps your column order, which is the honest view when the ordering itself carries meaning, such as increasing lookback. The same correlations are present here, but the block structure is harder to see because related features are separated by whichever columns happened to be built between them.

fig = q.plot.correlation_heatmap(
    features,
    title="AAPL feature library, source order",
)
fig.show()

Zoom into one feature family

Select columns by name or shell-style pattern to inspect a single family. Small matrices are annotated automatically, and triangle="lower" with diagonal=False removes the mirrored half and the trivial self-correlations.

Adjacent momentum horizons overlap heavily while distant ones do not, which is the usual argument for keeping a short and a long lookback rather than every lookback in between.

fig = q.plot.correlation_heatmap(
    features,
    columns="mom_*",
    triangle="lower",
    diagonal=False,
    title="Momentum horizons overlap with their neighbors",
)
fig.show()

Rank correlation across assets

The same helper answers portfolio questions. Here each asset contributes returns at several horizons, and method="spearman" measures monotonic co-movement using ranks, which is far less sensitive to the extreme return days that dominate a Pearson estimate.

Clustering separates crypto from the two equity series, while the equity pairs group by horizon rather than by ticker.

prices = pd.DataFrame(
    {
        "AAPL": aapl["close"],
        "SPY": spy["close"],
        "BTCUSD": q.data.datasets.load("btcusd")["close"],
    }
).dropna()

horizons = pd.DataFrame(
    {
        f"{symbol}_{window}d": q.indicator.momentum(prices[symbol], window=window)
        for symbol in prices.columns
        for window in (1, 5, 20, 60)
    }
).dropna()

fig = q.plot.correlation_heatmap(
    horizons,
    method="spearman",
    cluster=True,
    colorbar_label="Spearman",
    title="Cross-asset return co-movement by horizon",
)
fig.show()

Reading the result

A correlation heatmap describes the inputs, not their usefulness: a block of near-identical features signals redundancy and unstable model coefficients, but a feature can be uncorrelated with everything else and still carry no signal. Pair this view with the scatterplot matrix before pruning, since a low linear correlation can still hide a strong non-linear relationship.

Useful adjustments: method="spearman" or "kendall" for outlier-resistant rank correlation, min_periods to require a minimum overlap for sparse histories, show_values=True to force annotations, cell_size to trade density against readability, and labels to swap raw column names for presentation labels.

Back to top