Skip to main content

7. Dynamic Hedge Ratios and Residual Trading

In this project we’ll cover:

1) Dynamic hedging as an economic relationship

We start with the hedge ratio, which tells us how much of another asset, or how much of a hedge basket, we need to short when we hold a target asset and want to reduce an exposure they share. The target is the asset we want to hedge, and the explanatory return comes from the hedge asset. We’ll estimate that ratio from their return relationship.

In the simplest one-hedge case, the statistical model is:

\[ y_t = \alpha + \beta x_t + \varepsilon_t \]

where:

  • \(y_t\) is the target return at time \(t\),
  • \(x_t\) is the hedge return at time \(t\),
  • \(\alpha\) is the average return that isn’t explained by the hedge,
  • \(\beta\) is the hedge ratio,
  • \(\varepsilon_t\) is the residual, meaning the part of the target move that the hedge didn’t explain.

If \(\beta = 1.2\), the interpretation is direct: a 1% move in the hedge asset is associated with about a 1.2% move in the target. A hedged book then holds the target and shorts \(1.2\) units of the hedge:

\[ r^{hedged}_t = y_t - \beta x_t \]

The resulting return has a useful role in two related problems. In pure hedging, we want \(r^{hedged}_t\) to be less volatile than \(y_t\). In residual trading, we ask for something stronger: we want a residual spread to behave like a mean-reverting spread. We’ll work through hedging and residual trading as two connected layers.

The first layer is risk reduction:

How much of the target’s systematic exposure can we remove with the available hedge assets?

The second layer is residual trading:

After hedging, does the leftover spread mean-revert enough to trade?

A hedge can reduce risk very effectively while its residual still gives us no useful trading signal. Two returns can be highly correlated even when their price spread fails a stationarity test. We’ll study the risk of the hedged return first, and then examine the separate price-spread relationship for trading.

Show code
from dataclasses import dataclass
import math
import warnings

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from cycler import cycler
from IPython.display import display

from quantfinlab.risk import (capm_ols, drawdown_series, historical_es,
                              max_drawdown, rolling_beta, total_return)
from quantfinlab.portfolio.universe import prices_to_returns
from quantfinlab.dataio import load_yfinance_panel
from quantfinlab.hedging.metrics import residual_trade_table

warnings.filterwarnings("ignore")
pd.set_option("display.max_columns", 80)
pd.set_option("display.width", 180)

colors = ["#069AF3", "#FE420F", "#00008B", "#008080", "#CC79A7",
           "#9614fa", "#DC143C", "#7BC8F6", "#0072B2", "#04D8B2", "#800080", "#FF8072"]
plt.rcParams["axes.prop_cycle"] = cycler(color=colors)
plt.rcParams.update({
    "figure.figsize": (6, 3),
    "figure.dpi": 200,
    "savefig.dpi": 300,
    "axes.grid": True,
    "grid.alpha": 0.20,
    "axes.spines.top": False,
    "axes.spines.right": False,
    "axes.titlesize": 12,
    "axes.labelsize": 12,
    "xtick.labelsize": 9,
    "ytick.labelsize": 9,
    "legend.fontsize": 7})

Regression and ordinary least squares

Before estimating hedge ratios, let’s build the regression model from the beginning. We observe a target return \(y_t\) and one or more possible hedge returns on the same trading day. Regression gives us a way to describe how the target tends to move with those hedge returns and to measure the part left over.

For a single hedge, we write:

\[ y_t = \alpha + \beta x_t + \varepsilon_t, \qquad t=1,\ldots,T \]

Here \(T\) is the number of paired observations in the estimation sample. The intercept \(\alpha\) is the model’s average target return when \(x_t=0\), and the slope \(\beta\) measures how much the conditional average of \(y_t\) changes for a one-unit change in \(x_t\). The error \(\varepsilon_t\) collects the part of the target return that the linear relationship doesn’t explain.

If the conditional mean of the error is zero, the model implies:

\[ E[y_t\mid x_t] = \alpha + \beta x_t, \qquad E[\varepsilon_t\mid x_t]=0 \]

The first equation is a statement about the average target return at a given hedge return. Actual daily observations will sit above and below that line because asset returns also respond to other factors and shocks. A linear association describes the co-movement in our sample; the slope alone doesn’t establish that a movement in one asset caused the movement in the other.

For returns expressed in the same units, \(\beta\) is a ratio of return changes. A positive slope means the target tends to move in the same direction as the hedge; a negative slope means it tends to move in the opposite direction. Its magnitude determines how much hedge exposure is associated with one unit of target exposure.

From observations to a fitted line

We don’t know \(\alpha\) and \(\beta\) in advance, so we estimate them from the observed pairs \((x_t,y_t)\). For any candidate intercept \(a\) and slope \(b\), the fitted value and the fitted residual are:

\[ \hat y_t = a + bx_t, \qquad e_t = y_t-\hat y_t \]

The fitted value is the point on the candidate line at \(x_t\); the residual is the vertical distance from that line to the observed target return. The model error \(\varepsilon_t\) is an unobserved part of the data-generating relationship, while \(e_t\) is calculated after fitting a particular sample. We can inspect \(e_t\) directly, and we use it to assess the fit.

Ordinary least squares (OLS) chooses the intercept and slope that minimize the sum of squared residuals:

\[ (\hat\alpha,\hat\beta) = \arg\min_{a,b}\sum_{t=1}^{T}(y_t-a-bx_t)^2 \]

Squaring gives positive and negative errors the same treatment, and larger misses receive more weight. The sum uses every paired observation in the fitting window, so one unusually large daily move can have a visible influence on the fitted line. Once the coefficients are estimated, we write the predictions as \(\hat y_t=\hat\alpha+\hat\beta x_t\) and the residuals as \(\hat\varepsilon_t=y_t-\hat y_t\).

We can picture the procedure as moving and rotating a line through a scatter plot of the paired returns until its total squared vertical distance from the points is as small as possible. The result depends on the observations used in the fit, so a different sample can give us different coefficients.

Deriving the one-variable OLS coefficients

Write \(Q(a,b)=\sum_{t=1}^{T}(y_t-a-bx_t)^2\). At the minimum, its derivatives with respect to the two coefficients are zero:

\[ \frac{\partial Q}{\partial a}=-2\sum_{t=1}^{T}(y_t-a-bx_t)=0, \qquad \frac{\partial Q}{\partial b}=-2\sum_{t=1}^{T}x_t(y_t-a-bx_t)=0 \]

The first condition says that the fitted residuals sum to zero when we include an intercept. If \(\bar x=T^{-1}\sum_t x_t\) and \(\bar y=T^{-1}\sum_t y_t\), it gives:

\[ \hat\alpha = \bar y-\hat\beta\bar x \]

Substituting that intercept into the second condition gives the slope:

\[ \hat\beta = \frac{\sum_{t=1}^{T}(x_t-\bar x)(y_t-\bar y)} {\sum_{t=1}^{T}(x_t-\bar x)^2} =\frac{\widehat{\operatorname{Cov}}(x,y)}{\widehat{\operatorname{Var}}(x)} \]

The numerator measures how the two returns move together around their sample averages. The denominator measures how much the hedge return varies around its own average. Their ratio scales the co-movement by the variation in the hedge. Since \(\hat\beta=\hat\rho_{xy}\hat\sigma_y/\hat\sigma_x\), holding correlation and target volatility fixed, a more volatile hedge requires fewer units for the same exposure.

The slope requires variation in \(x_t\); if every hedge return in the fitting sample were identical, the denominator would be zero and the slope couldn’t be estimated from that sample. With a fitted intercept, the line also passes through \((\bar x,\bar y)\), which follows directly from the intercept equation.

Multiple regression and the design matrix

Often a single asset can’t describe the exposures we want to remove. With \(K\) hedge returns, we can estimate their coefficients together:

\[ y_t=\alpha+\beta_1x_{1,t}+\beta_2x_{2,t}+\cdots+\beta_Kx_{K,t}+\varepsilon_t \]

Each \(\beta_j\) describes the fitted change associated with \(x_{j,t}\) while the other hedge returns are held fixed. For example, a stock can be regressed on a sector ETF and a broad market ETF together. The two coefficients then describe their contributions within the same fitted relationship, including any overlap between the hedge assets.

We arrange the observations in a design matrix with one row per date and one column per coefficient. The first column is all ones for the intercept:

\[ X=\begin{bmatrix} 1 & x_{1,1} & \cdots & x_{K,1}\\ 1 & x_{1,2} & \cdots & x_{K,2}\\ \vdots & \vdots & & \vdots\\ 1 & x_{1,T} & \cdots & x_{K,T} \end{bmatrix},\quad \boldsymbol\theta=\begin{bmatrix}\alpha\\\beta_1\\\vdots\\\beta_K\end{bmatrix},\quad \mathbf y=\begin{bmatrix}y_1\\y_2\\\vdots\\y_T\end{bmatrix} \]

The same least-squares problem is now \(\min_{\boldsymbol\theta}\lVert\mathbf y-X\boldsymbol\theta\rVert^2\). Differentiating gives the normal equations and, when the columns of \(X\) are linearly independent, the familiar solution:

\[ X^\top X\hat{\boldsymbol\theta}=X^\top\mathbf y, \qquad \hat{\boldsymbol\theta}=(X^\top X)^{-1}X^\top\mathbf y \]

Here \(X\) has \(T\) rows and \(K+1\) columns; the estimated vector contains one intercept and \(K\) hedge coefficients. In the implementation we use a least-squares solver, which finds the fit directly without explicitly forming the inverse. If hedge returns are very similar, their individual coefficients can be sensitive to small changes in the sample even when their combined fitted return is stable. Perfectly dependent columns don’t identify a unique set of individual coefficients.

OLS assumptions and coefficient estimates

The least-squares calculation always chooses coefficients to fit the observations it receives. To interpret those estimates as estimates of an underlying relationship, we also need assumptions about the observations and the errors. Written for the multiple-regression model \(\mathbf y=X\boldsymbol\theta+\boldsymbol\varepsilon\), the main assumptions have different roles:

  1. A linear conditional relationship. We model the conditional mean of the target as a linear combination of the included hedge returns and an intercept. The coefficients are linear even if we decide to include transformed explanatory variables.
  2. Enough independent information. The design matrix has full column rank, so no hedge column is an exact linear combination of the others. We also need more observations than fitted coefficients to estimate the residual variance below.
  3. Zero conditional mean. We assume \(E[\boldsymbol\varepsilon\mid X]=0\). Given the full set of hedge observations, the remaining error has no systematic average component. Under this assumption and full rank, the fitted coefficients are unbiased conditional on \(X\).
  4. Constant, uncorrelated errors for classical inference. The usual standard-error formula assumes the same error variance across observations and zero covariance between errors on different dates. These assumptions affect the usual variance calculation and efficiency result; they aren’t needed simply to calculate the OLS fit.
  5. Normal errors for exact small-sample tests. Joint normality of the errors conditional on \(X\), together with the classical conditions above, gives the familiar finite-sample \(t\) and \(F\) reference distributions. With financial returns, changes in volatility or dependence over time can call for a different estimate of coefficient uncertainty.

The zero-mean condition deserves attention. If an omitted factor moves the target and is correlated with a hedge return, some of that factor’s effect can be picked up by the fitted hedge coefficient. The coefficient still describes the fitted association in our sample, but interpreting it requires knowing which returns are included in the regression and which exposures are left in the error.

When the classical assumptions hold, OLS has the lowest coefficient variance among linear unbiased estimators, the Gauss–Markov result. Overlapping hedge assets can nevertheless make individual estimates imprecise: their shared variation makes it harder to separate their contributions to the fitted target return.

Residuals, fitted variation, and estimation uncertainty

With an intercept, the normal equations give \(X^\top\hat{\boldsymbol\varepsilon}=0\). In the fitted sample, the residuals sum to zero and their sample inner product with each column of \(X\) is zero. That is an algebraic property of OLS on the observations used in the fit; it doesn’t promise the same relationship on new dates.

The residual sum of squares measures variation around the fitted line:

\[ RSS=\sum_{t=1}^{T}\hat\varepsilon_t^2, \qquad \hat\sigma^2=\frac{RSS}{T-K-1} \]

The denominator subtracts the \(K\) slopes and the intercept estimated from the same \(T\) observations. Under the classical assumptions of a correctly specified conditional mean, constant error variance, and uncorrelated errors, \(\hat\sigma^2\) estimates the error variance when there are enough observations and the design matrix has full column rank.

Under those assumptions, the estimated covariance matrix of the fitted coefficients is:

\[ \widehat{\operatorname{Var}}(\hat{\boldsymbol\theta}\mid X) =\hat\sigma^2(X^\top X)^{-1} \]

The square root of a diagonal entry is the standard error of that coefficient. The off-diagonal entries describe how pairs of coefficient estimates vary together across samples with the same design matrix; they also matter when we calculate uncertainty for a combination of coefficients. Financial time series can have changing volatility and serial dependence, so inference may require standard errors suited to those features.

These calculations describe uncertainty in the fitted coefficients and give us the inputs for the tests and intervals below. We also measure fit with \(R^2\) and explain its relationship to residual variance when we reach the hedge diagnostics.

Coefficient tests and confidence intervals

A coefficient estimate comes with sampling uncertainty. For a fitted coefficient \(\hat\theta_j\), its standard error \(SE(\hat\theta_j)\) is the square root of the corresponding diagonal entry of the estimated covariance matrix above. To test a proposed value \(\theta_{j,0}\), we form:

\[ t_j=\frac{\hat\theta_j-\theta_{j,0}}{SE(\hat\theta_j)} \]

The numerator is the distance between our estimate and the value being tested; the denominator expresses that distance in estimated standard-error units. A common test uses \(\theta_{j,0}=0\) to ask whether the coefficient is distinguishable from zero under the assumed regression model. The associated \(p\)-value measures how unusual a statistic at least as extreme as the observed one would be if the null value and the test assumptions held.

For a two-sided confidence interval, we use a critical value from the relevant reference distribution:

\[ \hat\theta_j\ \pm\ t_{1-\alpha_{sig}/2,\,T-K-1}\,SE(\hat\theta_j) \]

Here \(\alpha_{sig}\) is the chosen significance level and \(T-K-1\) is the residual degrees of freedom. For example, \(\alpha_{sig}=0.05\) gives a 95% interval. The interval describes the range of coefficient values compatible with the estimate at that confidence level under the assumptions used for the standard error. Its width increases when the coefficient is estimated less precisely.

We can also test several restrictions together. If a restricted regression imposes \(q\) restrictions and an unrestricted regression estimates all \(K\) slopes, their residual sums of squares give the classical statistic:

\[ F=\frac{(RSS_R-RSS_U)/q}{RSS_U/(T-K-1)} \]

The numerator measures the fit lost per restriction, and the denominator estimates the unrestricted error variance. Under the classical normal-error assumptions, we compare this statistic with an \(F\) distribution with \(q\) and \(T-K-1\) degrees of freedom. For example, we could test whether a group of hedge coefficients is jointly zero.

We use OLS to estimate hedge ratios, then measure the risk of the resulting positions. In our model tables, we focus on the hedge-return paths and their risk measures; the coefficient tests and confidence intervals above give us additional statistical background for interpreting the fitted coefficients.

From a regression coefficient to a hedge position

For the one-variable regression, the estimated slope is the sample covariance of target and hedge returns divided by the sample variance of the hedge. That is exactly the ratio we need when choosing the hedge that minimizes the variance of a target-plus-hedge book. We’ll derive the minimum-variance result below and then extend the book to several hedge assets.

The intercept has a different role from a hedge coefficient. It locates the regression line, but there is no asset position corresponding to the intercept. We therefore estimate it as part of the regression and use the slopes to size the hedge positions. For one target and one hedge, the fitted regression residual subtracts both the estimated intercept and the hedge contribution; the return of a book holding the target and shorting the hedge subtracts the hedge contribution from the target return.

The observations used to estimate a coefficient also matter. We first fit a regression on historical returns, then apply an available estimate to returns that follow. This turns the fitted relationship into a position whose remaining risk we can measure through time.

1.1 Hedge ratio as a minimum-variance problem

The regression coefficient has a trading meaning. In the one-hedge case, we can derive the optimal hedge ratio by minimizing the variance of the residual book:

\[ \min_{\beta} \; \text{Var}(y_t - \beta x_t) \]

If \(\sigma_y^2\) and \(\sigma_x^2\) are the two return variances and \(\sigma_{xy}\) is their covariance, we can expand the variance:

\[ \text{Var}(y_t - \beta x_t) = \sigma_y^2 - 2\beta\sigma_{xy} + \beta^2\sigma_x^2 \]

Taking the derivative with respect to \(\beta\) and setting it to zero gives:

\[ \frac{\partial}{\partial \beta}\left(\sigma_y^2 - 2\beta\sigma_{xy} + \beta^2\sigma_x^2\right)= -2\sigma_{xy}+2\beta\sigma_x^2 = 0 \]

So the minimum-variance hedge ratio is:

\[ \beta^* = \frac{\text{Cov}(y_t,x_t)}{\text{Var}(x_t)} \]

With an intercept, the one-variable OLS slope has this same covariance-over-variance form. Estimating the slope from a sample therefore gives us the hedge ratio that minimizes the variance of that sample’s residual book. The intercept shifts the fitted regression line, while the slope determines the position in the hedge asset.

For multiple hedge assets, the same idea becomes:

\[ \boldsymbol{\beta}^* = \Sigma_{xx}^{-1}\boldsymbol{\sigma}_{xy} \]

where \(\Sigma_{xx}\) is the covariance matrix of hedge returns and \(\boldsymbol{\sigma}_{xy}\) is the vector of covariances between each hedge and the target. This formula also shows why multicollinearity matters. If hedge assets are highly correlated, \(\Sigma_{xx}\) becomes harder to invert, and the estimated beta vector can become unstable.

1.2 Hedge quality and risk reduction

A hedge ratio is meant to reduce an exposure in the target return. The residual can still have a positive or negative average return, so the average return and the reduction in risk answer different questions. For the hedge-ratio comparisons, we focus on how much unwanted systematic risk remains after the hedge is applied.

Several outcomes would make the hedge useful:

\[ \text{Vol}(r^{hedged}) < \text{Vol}(y) \]

\[ ES_{5\%}(r^{hedged}) \text{ is less negative than } ES_{5\%}(y) \]

\[ |\beta(r^{hedged}, x)| < |\beta(y,x)| \]

Each measure captures a different part of the return distribution. A hedge can reduce ordinary day-to-day volatility while leaving a large loss in the tail, and it can remove exposure to a proxy while another source of risk remains in the book. The path of the hedged return can also have a larger drawdown even when its daily volatility is lower.

We’ll therefore look at volatility, tail risk, drawdown, and remaining beta together. The regression’s \(R^2\) helps us understand the fitted relationship, while the realized hedged return lets us evaluate how that relationship performs as a position.

2) Data construction

We combine the ETF and stock panels from the repository’s data layer. We read the prepared files from ../data/; each source folder in the public repository contains the steps for creating the corresponding CSV or parquet file.

For reproduction, check these folders inside the data directory:

Each folder contains a README and the relevant download or build script. We use the core and sector ETF panels for broad and sector exposures, the NASDAQ panel for individual stocks, and the international panel when we repeat the workflow on country ETFs. We align the panels on dates before calculating the return series, so targets and hedge assets can be compared on the same trading days.

The first application mixes ETFs and single stocks. That gives us three different hedge problems:

Relationship type Example What the hedge is trying to remove
Close ETF proxy QQQ vs SPY, TLT vs IEF, AGG vs BND broad common exposure such as equity beta, duration, or aggregate bond risk
Macro/factor proxy HYG vs LQD/TLT/SPY, GDX vs GLD/SPY credit, rates, equity beta, gold, and commodity-linked risk
Stock versus sector/factor basket NVDA vs SMH/QQQ, AMZN vs XLY/QQQ market, sector, and style exposure so the residual is closer to stock-specific behavior

We build both log returns and simple returns:

\[ r^{log}_t = \log(P_t) - \log(P_{t-1}) \]

\[ r^{simple}_t = \frac{P_t}{P_{t-1}} - 1 \]

Here \(P_t\) is the adjusted close price at time \(t\). Log returns are convenient for regression and statistical testing because they add cleanly across time. Simple returns are more natural for portfolio simulation because the hedged book’s profit and loss is calculated from actual percentage returns.

We estimate the return relationships with log returns, then evaluate the hedge using simple returns. That keeps the regression input convenient while making the simulated book’s percentage returns and compounding easier to interpret.

Show code
start = "2015-01-01"
end = "2026-01-01"

etf_px = load_yfinance_panel(["../data/core_cross_asset_etfs.csv", "../data/sector_etfs.csv"],
                            fields=("close",), start=start, end=end, lowercase=True)["close"]
stock_px = load_yfinance_panel("../data/nasdaq_close_volume.parquet", fields=("close",),
                              start=start, end=end, lowercase=True)["close"]

px = pd.concat([stock_px, etf_px], axis=1)
px = px.loc[:, ~px.columns.duplicated(keep="last")]
px = px.loc[(px.index >= start) & (px.index <= end)].sort_index()

ret_m = prices_to_returns(px, kind="log")
ret_b = prices_to_returns(px, kind="simple")

print(f"etf close panel: {etf_px.shape}")
print(f"stock close panel: {stock_px.shape}")
print(f"combined price panel from {start} to {end}: {px.shape}")
etf close panel: (2766, 32)
stock close panel: (2766, 4382)
combined price panel from 2015-01-01 to 2026-01-01: (2766, 4414)

After loading and aligning the local ETF and NASDAQ panels, we have 2766 daily ETF observations, 4382 stock columns, and 4414 combined price columns from 2015 to the start of 2026. The stock and ETF panels give us enough history to compare close ETF proxies with single-name relationships that have more company-specific risk. We can use the same return dates when we compare targets with their chosen hedge assets.

The 2015 start date also gives the sample several very different regimes:

  • the low-rate expansion before 2020,
  • the COVID crash and recovery,
  • the inflation/rate shock of 2022,
  • the AI and mega-cap growth rebound after 2023.

A hedge ratio estimated in one period may behave differently as those regimes change. We can compare static, rolling, ridge, and Kalman estimates across the same relationships to see how much a changing coefficient helps each hedge.

2.1 Financial relationships in the universe

We choose targets and hedges with a clear financial connection. Their shared exposures give us a reason to estimate a relationship, and their differences help us interpret the return left after the hedge. The same regression can have a very different meaning for two bond funds than it has for a stock and its sector basket.

For example, AGG and BND are both broad US aggregate bond ETFs, so their holdings should give them a close common return component. QQQ and SPY both carry US equity exposure, while QQQ has more growth and technology exposure; their residual can therefore contain a growth tilt. TLT and IEF both hold Treasuries, so their hedge ratio should reflect the difference in duration exposure. HYG and LQD both hold corporate credit, but HYG has greater default and equity-cycle sensitivity.

For individual stocks, we use sector and market ETFs as partial explanations. Hedging NVDA with SMH and QQQ removes part of the semiconductor and Nasdaq growth exposure. The remaining return is closer to NVDA-specific information, although it can still contain risk factors that aren’t represented in the hedge basket.

We can compare the regression’s \(R^2\) with the financial role of each hedge and then examine the exposure that remains in the residual. This connects the fit of each relationship with a concrete interpretation of what the target and its hedge assets hold.

3) Target and hedge relationships

We define each hedge problem as a relationship with one target and one or more hedge assets. In a one-hedge case, the model is:

\[ y_t = \alpha + \beta x_t + \varepsilon_t \]

If we have \(K\) hedge assets, each with return \(x_{j,t}\) and coefficient \(\beta_j\), the relationship becomes:

\[ y_t = \alpha + \beta_1 x_{1,t} + \beta_2 x_{2,t} + \cdots + \beta_K x_{K,t} + \varepsilon_t \]

or in vector notation:

\[ y_t = \alpha + \mathbf{x}_t^\top \boldsymbol{\beta} + \varepsilon_t \]

The hedge-book return is then:

\[ r^{hedged}_t = y_t - \sum_{j=1}^{K}\beta_{j,t-1}x_{j,t} \]

The lag on \(\beta_{t-1}\) is critical. It means we trade using yesterday’s available hedge ratio, not today’s hindsight hedge ratio. Each coefficient scales the corresponding hedge return, so the book subtracts the combined exposure of all \(K\) hedges.

The relationships are economically different:

  • QQQ versus SPY measures growth-heavy Nasdaq exposure relative to broad US equity beta.
  • TLT versus IEF measures long-duration exposure relative to intermediate-duration Treasuries.
  • AGG versus BND is almost a replication problem between two broad aggregate bond ETFs.
  • HYG versus LQD/TLT/SPY tries to isolate high-yield credit after investment-grade credit, rates, and equity beta.
  • GDX versus GLD/SPY separates gold miners from gold price and equity market components.
  • Single-stock relationships hedge mega-cap or high-beta stocks with sector ETFs and QQQ, trying to remove market/sector beta and leave idiosyncratic residual behavior.
Show code
@dataclass(frozen=True)
class rel:
    name: str
    target: str
    hedges: list[str]
    pair: tuple[str, str] | None = None

    @property
    def assets(self):
        return [self.target, *self.hedges]

    @property
    def hedge_label(self):
        return "+".join(self.hedges)


def filter_rels(rels, columns):
    available = set(map(str.lower, columns))
    kept, missing = [], {}
    for r in rels:
        miss = [t for t in r.assets if t not in available]
        if miss:
            missing[r.name] = miss
        else:
            kept.append(r)
    return kept, missing


def rel_table(rels, columns):
    available = set(map(str.lower, columns))
    rows = []
    for r in rels:
        rows.append({"relationship": r.name, "target": r.target, "hedges": ", ".join(r.hedges),
                     "residual_pair": "" if r.pair is None else " / ".join(r.pair),
                     "included": all(t in available for t in r.assets)})
    return pd.DataFrame(rows)
Show code
rel_main = [
    rel("qqq_spy", "qqq", ["spy"], pair=("qqq", "spy")),
    rel("tlt_ief", "tlt", ["ief"], pair=("tlt", "ief")),
    rel("agg_bnd", "agg", ["bnd"], pair=("agg", "bnd")),
    rel("hyg_credit", "hyg", ["lqd", "tlt", "spy"], pair=("hyg", "lqd")),
    rel("gdx_gold", "gdx", ["gld", "spy"], pair=("gdx", "gld")),
    rel("nvda_semi", "nvda", ["smh", "qqq"]),
    rel("amd_semi", "amd", ["smh", "qqq"]),
    rel("aapl_tech", "aapl", ["xlk", "qqq"]),
    rel("msft_tech", "msft", ["xlk", "qqq"]),
    rel("amzn_disc", "amzn", ["xly", "qqq"]),
    rel("tsla_disc", "tsla", ["xly", "qqq"]),
    rel("cost_def", "cost", ["xlp", "qqq"])]

rel_view = rel_table(rel_main, px.columns)
rel_main, missing_main = filter_rels(rel_main, px.columns)
tickers_main = list(dict.fromkeys(t for r in rel_main for t in r.assets))

if missing_main:
    display(pd.Series({k: ", ".join(v) for k, v in missing_main.items()}, name="missing_tickers").to_frame())
else:
    print("missing main relationship tickers: none")

display(rel_view)

px_main = px[tickers_main]
ret_m_main = ret_m[tickers_main]
ret_b_main = ret_b[tickers_main]
missing main relationship tickers: none
relationship target hedges residual_pair included
0 qqq_spy qqq spy qqq / spy True
1 tlt_ief tlt ief tlt / ief True
2 agg_bnd agg bnd agg / bnd True
3 hyg_credit hyg lqd, tlt, spy hyg / lqd True
4 gdx_gold gdx gld, spy gdx / gld True
5 nvda_semi nvda smh, qqq True
6 amd_semi amd smh, qqq True
7 aapl_tech aapl xlk, qqq True
8 msft_tech msft xlk, qqq True
9 amzn_disc amzn xly, qqq True
10 tsla_disc tsla xly, qqq True
11 cost_def cost xlp, qqq True

All the tickers for our chosen relationships are available in the price panel. For NVDA, for example, the relationship contains both SMH and QQQ, giving us a semiconductor hedge alongside the broader Nasdaq growth factor. We can evaluate the complete basket and see how the two hedge coefficients behave together. We check the time coverage of each relationship in the next step, before estimating its hedge ratios.

The relationships cover different kinds of hedging. AGG and BND are close bond-fund proxies, while TSLA against XLY and QQQ is a partial model of a single stock’s market and sector exposures. Comparing both types will help us see how a given estimator behaves when the target is closely matched to its hedge and when more stock-specific movement remains.

4) Coverage and first diagnostics

Before estimating hedge ratios, we inspect basic coverage and relationship quality. For each target and hedge proxy, we calculate annualized volatility and their return correlation:

\[ \sigma_y = \sqrt{252}\,\text{std}(y_t) \]

\[ \sigma_x = \sqrt{252}\,\text{std}(x_t) \]

\[ \rho_{yx} = \text{corr}(y_t, x_t) \]

When a relationship has several hedge assets, the diagnostic proxy is their equal-weight average return. For a one-factor regression on that proxy, the coefficient of determination is:

\[ R^2 = 1 - \frac{\sum_t \hat{\varepsilon}_t^2}{\sum_t (y_t - \bar{y})^2} \]

A high \(R^2\) means the hedge proxy explains a large share of the target’s return variance. A low \(R^2\) means the target has a large residual component, so hedging will be incomplete.

We also compute a rolling beta interquartile range:

\[ \text{IQR}(\beta) = Q_{0.75}(\beta_t) - Q_{0.25}(\beta_t) \]

The interquartile range measures the spread between the upper and lower quartiles of the rolling beta path. A relationship can have high return correlation and still have a wide beta IQR, meaning that its estimated hedge ratio changes substantially through time. Dynamic estimates give us a way to follow those changes.

Show code
ann = 252
win = 252

def hedge_proxy_ret(ret, r):
    return ret[r.hedges].mean(axis=1).rename(f"{r.name}_proxy")


def coverage_table(frame, rels):
    rows = []
    for r in rels:
        sub = frame[r.assets]
        valid = sub.dropna(how="any")
        rows.append({"relationship": r.name, "target": r.target, "hedges": ", ".join(r.hedges),
                     "start": valid.index.min(), "end": valid.index.max(),
                     "obs": len(valid), "missing_pct": sub.isna().mean().mean(), "included": len(valid) > 0})
    return pd.DataFrame(rows)


def diag_table(ret, rels):
    rows = []
    for r in rels:
        y = ret[r.target]
        proxy = hedge_proxy_ret(ret, r)
        z = pd.concat([y, proxy], axis=1).dropna()
        z.columns = ["target", "proxy"]
        _, _, r2 = capm_ols(z["target"], z["proxy"])
        b = rolling_beta(z["target"], z["proxy"], window=min(win, max(len(z) // 2, 20)))
        rows.append({"relationship": r.name, "target_vol": z["target"].std() * np.sqrt(ann),
                     "hedge_vol": z["proxy"].std() * np.sqrt(ann), "corr": z["target"].corr(z["proxy"]),
                     "r2": r2, "beta_iqr": b.quantile(0.75) - b.quantile(0.25), "obs": len(z)})
    return pd.DataFrame(rows)


tab_cov = coverage_table(px_main, rel_main)
tab_diag = diag_table(ret_m_main, rel_main)
display(tab_cov.round(4))
display(tab_diag.round(4))

fig, ax = plt.subplots(figsize=(9.5, 4.8))
diag_cols = ["target_vol", "hedge_vol", "corr", "r2", "beta_iqr"]
mat = tab_diag.set_index("relationship")[diag_cols]
vals = mat.to_numpy(dtype=float)
im = ax.imshow(vals, aspect="auto", cmap="viridis")
ax.set_xticks(range(mat.shape[1]), mat.columns, rotation=35, ha="right")
ax.set_yticks(range(mat.shape[0]), mat.index)
ax.set_title("relationship diagnostics")
for i in range(mat.shape[0]):
    for j in range(mat.shape[1]):
        if np.isfinite(vals[i, j]):
            ax.text(j, i, f"{vals[i, j]:.2f}", ha="center", va="center", fontsize=7, color="white")
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
plt.tight_layout()
plt.show()

fig, axes = plt.subplots(2, 2, figsize=(12, 6.4), sharex=True)
for ax, name in zip(axes.ravel(), ["qqq_spy", "tlt_ief", "nvda_semi", "tsla_disc"]):
    r = next(x for x in rel_main if x.name == name)
    b = rolling_beta(ret_m_main[r.target], hedge_proxy_ret(ret_m_main, r), window=win).dropna()
    ax.plot(b.index, b.values, lw=1.25)
    ax.axhline(0, color="black", lw=0.7, alpha=0.5)
    ax.set_title(f"{name}: rolling proxy beta")
    ax.set_ylabel("beta")
    ax.grid(True, alpha=0.25)
plt.tight_layout()
plt.show()

price_examples = [("agg_bnd", "agg", "bnd"), ("gdx_gld", "gdx", "gld"),
                  ("tlt_ief", "tlt", "ief"), ("xlk_qqq", "xlk", "qqq")]
fig, axes = plt.subplots(2, 2, figsize=(12, 6.4), sharex=True)
for ax, item in zip(axes.ravel(), price_examples):
    name, a, b = item
    sub = px[[a, b]].dropna()
    norm = sub / sub.iloc[0]
    ax.plot(norm.index, norm[a], lw=1.25, label=a, color=colors[0])
    ax.plot(norm.index, norm[b], lw=1.25, label=b, color=colors[1])
    ax.set_title(name)
    ax.legend(fontsize=7)
    ax.grid(True, alpha=0.25)
plt.tight_layout()
plt.show()
relationship target hedges start end obs missing_pct included
0 qqq_spy qqq spy 2015-01-02 2025-12-31 2766 0.0 True
1 tlt_ief tlt ief 2015-01-02 2025-12-31 2766 0.0 True
2 agg_bnd agg bnd 2015-01-02 2025-12-31 2766 0.0 True
3 hyg_credit hyg lqd, tlt, spy 2015-01-02 2025-12-31 2766 0.0 True
4 gdx_gold gdx gld, spy 2015-01-02 2025-12-31 2766 0.0 True
5 nvda_semi nvda smh, qqq 2015-01-02 2025-12-31 2766 0.0 True
6 amd_semi amd smh, qqq 2015-01-02 2025-12-31 2766 0.0 True
7 aapl_tech aapl xlk, qqq 2015-01-02 2025-12-31 2766 0.0 True
8 msft_tech msft xlk, qqq 2015-01-02 2025-12-31 2766 0.0 True
9 amzn_disc amzn xly, qqq 2015-01-02 2025-12-31 2766 0.0 True
10 tsla_disc tsla xly, qqq 2015-01-02 2025-12-31 2766 0.0 True
11 cost_def cost xlp, qqq 2015-01-02 2025-12-31 2766 0.0 True
relationship target_vol hedge_vol corr r2 beta_iqr obs
0 qqq_spy 0.2201 0.1783 0.9338 0.8720 0.1542 2765
1 tlt_ief 0.1504 0.0663 0.9163 0.8396 0.2781 2765
2 agg_bnd 0.0526 0.0537 0.9591 0.9198 0.0771 2765
3 hyg_credit 0.0831 0.0928 0.6638 0.4406 0.2101 2765
4 gdx_gold 0.3822 0.1182 0.6194 0.3837 0.8500 2765
5 nvda_semi 0.4836 0.2585 0.8094 0.6551 0.3215 2765
6 amd_semi 0.5782 0.2585 0.6392 0.4086 0.4010 2765
7 aapl_tech 0.2881 0.2273 0.8134 0.6617 0.1668 2765
8 msft_tech 0.2686 0.2273 0.8596 0.7388 0.1301 2765
9 amzn_disc 0.3280 0.2117 0.7675 0.5891 0.1925 2765
10 tsla_disc 0.5759 0.2117 0.6094 0.3714 0.7989 2765
11 cost_def 0.2163 0.1629 0.6772 0.4586 0.2274 2765

From the coverage table we can see that every main relationship has the full sample from 2015-01-02 to 2025-12-31, with 2766 price observations and 0% missing values. So any later difference across models is coming from the estimation method, not from unequal data coverage.

We can already see a difference between the easier hedge problems and the harder ones in the diagnostics:

  • AGG/BND has correlation around 0.959 and \(R^2\) around 0.920. This is the cleanest relationship in the main sample. It’s almost a replication hedge.
  • QQQ/SPY has correlation around 0.934 and \(R^2\) around 0.872. SPY explains most Nasdaq movement, but the residual still contains a growth/tech component.
  • TLT/IEF has correlation around 0.916 and \(R^2\) around 0.840. This is a duration hedge problem. The two ETFs move together, but TLT has much higher duration sensitivity.
  • GDX versus the GLD/SPY proxy has only moderate correlation around 0.619 and \(R^2\) around 0.384, with a very large beta IQR around 0.850. Gold miners aren’t just leveraged gold. They also contain equity beta, mining margin risk, and company-level stress.
  • TSLA/XLY+QQQ and AMD/SMH+QQQ have low-to-moderate explanatory power relative to their target volatility. Those names have major idiosyncratic components.

In the diagnostic heatmap we can see the same pattern: bond pairs are bright in correlation and \(R^2\), while GDX and high-volatility stocks stand out with higher target volatility and less stable betas.

4.1 Rolling beta and normalized price behavior

With the rolling beta paths, we can see why one static hedge ratio can be too rigid for some relationships. The rolling beta for QQQ/SPY stays mostly around the 1.1 to 1.3 zone, but it still shifts across regimes. During periods when growth stocks dominate, QQQ needs a higher SPY hedge to remove market beta. During stress or rotation periods, the beta compresses.

For TLT/IEF, the beta is much higher because TLT has longer duration. If IEF is a medium-duration Treasury ETF and TLT is long duration, then one dollar of IEF doesn’t offset one dollar of TLT. The hedge ratio has to scale up with duration exposure.

For NVDA/SMH+QQQ and TSLA/XLY+QQQ, the rolling paths are less stable. Their returns contain company-specific movements alongside their sector exposures. NVDA has AI, semiconductor, and company-specific earnings repricing, while TSLA has consumer discretionary beta, growth beta, rate sensitivity, and company-specific volatility. These different drivers can change the estimated sensitivity to each proxy through time. A hedge ratio can remove broad exposure, but it can’t turn these stocks into clean ETF spreads.

We can also compare normalized prices to see the same relationships from a price-level perspective. AGG and BND track very closely. TLT and IEF track directionally but with large divergence because their durations differ. GDX and GLD diverge sharply in some periods, especially when miner margins or equity risk dominate gold itself. XLK and QQQ are close but not identical, since QQQ is a Nasdaq index and XLK is a technology sector ETF.

4.2 Residual variance and \(R^2\) intuition

The diagnostic \(R^2\) is useful because it tells us how much of the target’s return variance can be explained by the hedge proxy. In a one-factor regression with intercept:

\[ R^2 = \rho_{yx}^2 \]

So if QQQ and SPY have correlation around \(0.934\), then \(R^2\) is about:

\[ 0.934^2 \approx 0.872 \]

This agrees with the full-sample estimate: about 87% of QQQ’s daily return variance is explained by SPY in this one-factor regression. The remaining 13% is the residual growth/technology component.

For a hedge book, the residual variance is:

\[ \sigma^2_{res} = \sigma_y^2(1-R^2) \]

in the simple one-factor static case. This explains why high-correlation relationships are easier to hedge. If \(R^2\) is high, the residual variance has to be much lower than target variance. If \(R^2\) is low, even the optimal hedge can only remove part of the target risk.

That’s why GDX is hard. Its relationship with GLD and SPY is real, but not dominant. A large part of GDX movement remains outside those hedges.

5) Hedge-book timing and estimation

With the relationships and their initial diagnostics in place, we can now define the timing of the hedge estimates. Daily observations are available from 2015 to the start of 2026, and the first part of that history is used to fit the models before we evaluate their hedge books.

We use the following settings for the first group of hedge models:

  1. Initial training window: two years of daily observations for the first fitted hedge ratio.
  2. Rolling window: one year of daily returns for each updated rolling estimate.
  3. Rebalancing: hedge positions can be updated on Fridays, using the available estimates.
  4. No-trade band: small changes in an estimated hedge ratio don’t always lead to a new position.
  5. Evaluation: the fitted hedge is compared with the unhedged target over the corresponding book dates.

The initial training window is:

\[ L_{train}=504 \quad \text{trading days} \approx 2 \text{ years} \]

The lookback used by rolling estimates is shorter:

\[ L_{roll}=252 \quad \text{trading days} \approx 1 \text{ year} \]

We rebalance weekly on Fridays. An estimate can change on other days, but the position only has a scheduled opportunity to change at the weekly rebalance. The daily book return is still evaluated on each available trading day.

The no-trade band is:

\[ b = 0.05 \]

If the desired hedge ratio differs from the traded ratio by no more than 5 percentage points, we leave that hedge position as it is. A larger difference updates it to the new desired ratio. We’ll use these windows and the weekly schedule when we build the books below, and then write out the band rule alongside the rolling estimates.

5.1 Hedge-book mechanics and static OLS

Now we build the hedge book. Starting with an estimated beta path, we calculate the book returns, turnover, costs, and NAV that let us evaluate the hedge.

For a target \(y_t\) and hedge vector \(\mathbf{x}_t\), the gross hedged return is:

\[ r^{gross}_t = y_t - \boldsymbol{\beta}_{t-1}^\top \mathbf{x}_t \]

The cost-adjusted return is:

\[ r^{net}_t = r^{gross}_t - c_t \]

where:

\[ c_t = \frac{\text{cost bps}}{10000}\sum_{j=1}^{K}|\beta_{j,t}-\beta_{j,t-1}| \]

An estimator that changes its beta frequently generates more trading in the hedge assets. The cost term charges for those changes, allowing us to compare the gross risk reduction with the net return of the position. For each hedge book, we track:

  • volatility reduction: how much annualized volatility falls relative to the unhedged target,
  • expected shortfall reduction: how much the left-tail loss improves,
  • maximum drawdown difference: whether the hedge reduces peak-to-trough pain,
  • beta reduction: how much residual exposure to the hedge proxy remains,
  • turnover and cost drag: whether the model is implementable.

Static OLS is the first benchmark. We estimate one fixed beta from the first 504 observations:

\[ \hat{\boldsymbol{\beta}}_{OLS} = (X^\top X)^{-1}X^\top y \]

Here \(X\) includes an intercept and the hedge returns, and \(y\) contains the target returns in the training window. The fitted coefficient vector includes the intercept as its first entry; we use its remaining entries as the hedge positions. We hold the estimated hedge ratio through the rest of the sample, with weekly book evaluation. Static OLS is simple and low-turnover, but it assumes the relationship is stable after the training period.

Our first hedge estimate uses the initial two-year training window. After that window, the hedge book is evaluated through time using the estimate available for each date; the static estimate stays fixed, while the other estimators can produce an updated path. The return on each date uses the previously available hedge position.

We charge 5 basis points on changes in the hedge positions. For a day’s total absolute change in hedge weights, the resulting trading cost is:

\[ \text{cost}_t = \text{turnover}_t \times \frac{5}{10000} \]

The turnover term sums the absolute changes across the hedge assets, so adding or reducing a position both contribute to the amount traded. We subtract that cost from the gross hedge return, as in the book equation above, and compare the resulting net return and risk measures across the models. This lets us see the effect of updating a hedge more often as well as the risk it removes.

Show code
n_train = 504
freq = "w-fri"
cost_bps = 5.0

def ols_fit(y, x):
    x = np.asarray(x, dtype=float)
    if x.ndim == 1:
        x = x.reshape(-1, 1)
    y = np.asarray(y, dtype=float).reshape(-1)
    xmat = np.column_stack([np.ones(len(y)), x])
    coef = np.linalg.lstsq(xmat, y, rcond=None)[0]
    return float(coef[0]), np.asarray(coef[1:], dtype=float)


def ols_beta(ret, r):
    z = ret[r.assets].dropna()
    out = pd.DataFrame(np.nan, index=ret.index, columns=r.hedges)
    _, b = ols_fit(z[r.target].iloc[:n_train], z[r.hedges].iloc[:n_train])
    out.loc[z.index[n_train - 1]:, r.hedges] = b
    return out


def rebalance_beta(beta, index):
    idx = pd.DatetimeIndex(index).sort_values()
    daily = beta.reindex(idx.union(beta.index)).sort_index().ffill().reindex(idx)
    dates = pd.Series(idx, index=idx).groupby(pd.Grouper(freq=freq.upper())).last().dropna()
    return daily.loc[pd.DatetimeIndex(dates.values)].dropna(how="all")


def zero_beta(index, r):
    return pd.DataFrame(0.0, index=pd.DatetimeIndex(index)[:1], columns=r.hedges)


def hedge_book(ret, r, beta, cost_bps=cost_bps):
    panel = ret[r.assets].dropna()
    b = beta.reindex(panel.index.union(beta.index)).sort_index().ffill().reindex(panel.index)
    b = b.shift(1).dropna(how="any")
    panel = panel.reindex(b.index).dropna(how="any")
    b = b.reindex(panel.index)
    gross = panel[r.target] - (b[r.hedges] * panel[r.hedges]).sum(axis=1)
    turnover = b.diff().abs().sum(axis=1)
    if len(turnover):
        turnover.iloc[0] = b.iloc[0].abs().sum()
    cost = turnover * cost_bps / 10000.0
    net = gross - cost
    return {"target_return": panel[r.target], "gross_return": gross, "net_return": net,
            "turnover": turnover, "cost": cost, "beta": b,
            "gross_values": (1 + gross).cumprod(), "net_values": (1 + net).cumprod(),
            "target_values": (1 + panel[r.target]).cumprod()}


class book_result:
    def __init__(self, d):
        self.__dict__.update(d)
        self.net_returns = d["net_return"]
        self.gross_returns = d["gross_return"]



def model_table(bt, rels, ret):
    rows = []
    for r in rels:
        base_name = f"{r.name} | target"
        base = bt[base_name].net_returns
        proxy = hedge_proxy_ret(ret, r)
        zbase = pd.concat([base, proxy], axis=1).dropna()
        _, beta_base, _ = capm_ols(zbase.iloc[:, 0], zbase.iloc[:, 1])
        for name, res in bt.items():
            if not name.startswith(f"{r.name} | ") or name == base_name:
                continue
            model = name.split(" | ", 1)[1]
            z = pd.concat([base.rename("base"), res.net_returns.rename("model")], axis=1).dropna()
            vol_base = z["base"].std() * np.sqrt(ann)
            vol_model = z["model"].std() * np.sqrt(ann)
            es_base = historical_es(z["base"], alpha=0.05)
            es_model = historical_es(z["model"], alpha=0.05)
            zbeta = pd.concat([res.net_returns, proxy], axis=1).dropna()
            _, beta_model, _ = capm_ols(zbeta.iloc[:, 0], zbeta.iloc[:, 1])
            bpath = res.beta[r.hedges]
            b_iqr = bpath.quantile(0.75).sub(bpath.quantile(0.25)).mean()
            b_jump = bpath.diff().abs().mean().mean()
            cost_s = res.cost
            rows.append({"relationship": r.name, "model": model,
                         "vol_red": 1 - vol_model / vol_base if vol_base > 1e-12 else np.nan,
                         "es_red": 1 - abs(es_model) / abs(es_base) if abs(es_base) > 1e-12 else np.nan,
                         "maxdd_diff": (abs(max_drawdown(z["base"], input_kind="returns"))
                                        - abs(max_drawdown(z["model"], input_kind="returns"))),
                         "beta_red": 1 - abs(beta_model) / abs(beta_base) if abs(beta_base) > 1e-12 else np.nan,
                         "turnover": res.turnover.mean() if len(res.turnover) else 0.0,
                         "turnover_ann": res.turnover.mean() * ann if len(res.turnover) else 0.0,
                         "cost_drag": max(total_return(res.gross_values) - total_return(res.net_values), 0.0),
                         "cost_drag_ann": cost_s.mean() * ann if len(cost_s) else 0.0,
                         "beta_iqr": b_iqr, "beta_jump": b_jump,
                         "beta_stability": (1 / (1 + b_iqr + 5 * b_jump)
                                            if np.isfinite(b_iqr) and np.isfinite(b_jump) else np.nan),
                         "rebalance_count": int((res.turnover.abs() > 1e-12).sum()) if len(res.turnover) else 0})
    return pd.DataFrame(rows)

5.2 Reading the hedge metrics

The risk-reduction metrics have specific meanings. Volatility reduction is:

\[ \text{vol\_red} = 1 - \frac{\sigma_{hedged}}{\sigma_{target}} \]

We compare expected shortfalls in the same way, using absolute loss magnitudes:

\[ \text{es\_red} = 1 - \frac{|ES_{hedged}|}{|ES_{target}|} \]

where \(ES\) is the average loss inside the left tail. If \(ES_{hedged}\) is much smaller in absolute value, the hedge improves tail risk.

The drawdown metric is reported as an improvement in absolute drawdown:

\[ \text{maxdd\_diff} = |MDD_{target}| - |MDD_{hedged}| \]

Both drawdowns are measured on their respective return paths. Positive values mean the hedge reduced maximum drawdown. Negative values mean the hedged book had a worse drawdown than the original target.

Beta reduction is:

\[ \text{beta\_red} = 1 - \frac{|\beta_{hedged}|}{|\beta_{target}|} \]

This asks whether the residual book really removed exposure to the hedge proxy. If beta reduction is high but drawdown improvement is bad, the hedge removed the intended factor but introduced or exposed another source of risk.

Show code
books_target = {f"{r.name} | target": book_result(hedge_book(ret_b_main, r, zero_beta(ret_b_main.index, r)))
                for r in rel_main}
books_static, blog, beta_daily = {}, {}, {}

for r in rel_main:
    beta_desired = ols_beta(ret_m_main, r)
    beta_traded = rebalance_beta(beta_desired, ret_b_main.index).ffill()
    books_static[f"{r.name} | static"] = book_result(hedge_book(ret_b_main, r, beta_traded))
    blog[f"{r.name} | static"] = {"rel": r.name, "model": "static",
                                  "desired": beta_desired, "traded": beta_traded}

bt_static = {**books_target, **books_static}
tab_static = model_table(bt_static, rel_main, ret_m_main)
model_cols = ["relationship", "vol_red", "es_red", "maxdd_diff", "beta_red", "turnover_ann", "cost_drag_ann"]
display(tab_static[model_cols].round(4))

for name in ["qqq_spy", "nvda_semi"]:
    fig, axes = plt.subplots(2, 1, figsize=(8.8, 5.5), sharex=True)
    for key in [f"{name} | target", f"{name} | static"]:
        nav = bt_static[key].net_values / bt_static[key].net_values.iloc[0]
        axes[0].plot(nav.index, nav, label=key)
        axes[1].plot(nav.index, drawdown_series(nav, input_kind="nav"), label=key)
    axes[0].set_title(name)
    axes[0].legend(fontsize=8)
    axes[0].grid(True, alpha=0.25)
    axes[1].grid(True, alpha=0.25)
    plt.tight_layout()
plt.show()
relationship vol_red es_red maxdd_diff beta_red turnover_ann cost_drag_ann
0 qqq_spy 0.6417 0.6509 0.1496 0.9547 0.1236 0.0001
1 tlt_ief 0.5837 0.5382 0.1944 0.9163 0.2484 0.0001
2 agg_bnd 0.7153 0.8112 0.1456 0.9857 0.1061 0.0001
3 hyg_credit 0.4197 0.4741 0.0097 0.8018 0.1523 0.0001
4 gdx_gold 0.2050 0.2059 -0.4645 0.3172 0.3950 0.0002
5 nvda_semi 0.4428 0.4700 0.1705 0.8676 0.1256 0.0001
6 amd_semi 0.2853 0.2980 -0.0520 0.9804 0.2119 0.0001
7 aapl_tech 0.3972 0.4234 -0.0960 0.8054 0.2021 0.0001
8 msft_tech 0.4482 0.4510 -0.0930 0.7224 0.1370 0.0001
9 amzn_disc 0.3862 0.4278 0.0531 0.9345 0.1405 0.0001
10 tsla_disc 0.1885 0.2002 0.1003 0.6605 0.1285 0.0001
11 cost_def 0.2668 0.2515 0.0815 0.7876 0.0985 0.0000

With static OLS, we can already remove a large amount of risk from several relationships using a fixed hedge ratio over the evaluation sample.

The cleanest case is AGG/BND, with about 71.5% volatility reduction and 81.1% expected-shortfall reduction. That makes sense because AGG and BND are both broad aggregate bond ETFs. A static hedge works because the relationship is structurally stable.

QQQ/SPY also works very well, with about 64.2% volatility reduction and 65.1% ES reduction. SPY removes a large part of QQQ’s broad equity movement in this sample, leaving a residual with a growth and technology tilt. The hedged position still carries the exposure that differs between these two equity funds.

TLT/IEF gives about 58.4% volatility reduction and a very strong drawdown improvement around 19.4 percentage points. That’s a duration-scaling hedge. IEF doesn’t perfectly offset TLT, but it captures much of the Treasury-rate factor.

For GDX versus GLD/SPY, static hedging reduces volatility by about 20.5%, while the maximum drawdown difference is negative by about 46 percentage points. The two numbers show how the fixed hedge reduces daily variation while leaving a difficult cumulative return path. Gold miners can sell off with equities even when gold behaves defensively. A static GLD/SPY mix doesn’t fully capture that regime-switching behavior.

For the single stocks, static hedging lowers volatility meaningfully, especially NVDA, AAPL, MSFT, and AMZN. But drawdown improvement isn’t universal. AMD, AAPL, and MSFT show negative max-drawdown differences under static hedging, which means the hedge reduced day-to-day volatility but didn’t necessarily protect the worst path. Lower day-to-day variation and a better peak-to-trough path are different properties, so we need to inspect both risk measures for the hedged positions.

With the NAV and drawdown paths for QQQ/SPY and NVDA/SMH+QQQ, we can see the difference between hedging an exposure and holding the target outright.

For QQQ/SPY, the static hedged book is much flatter than the target. Shorting SPY removes much of QQQ’s broad equity exposure, leaving a relative return that reflects how QQQ behaves against the wider US equity market over the evaluation period. We can also see much smaller drawdowns than unhedged QQQ, especially around the 2020 selloff and the 2022 growth-stock decline.

For NVDA, the target NAV explodes after 2023 because NVDA had a huge idiosyncratic and AI-related rally. A hedged book removes some semiconductor and Nasdaq beta, but it also removes part of the exposure that helped NVDA compound. This is a common practical tradeoff:

A hedge that removes systematic downside also removes systematic upside. The residual book is cleaner, but it won’t behave like the original target asset.

So the correct evaluation depends on the goal. If the goal is pure exposure to NVDA, hedging looks like giving up upside. If the goal is isolating NVDA-specific alpha after sector and growth exposure, hedging is the right tool.

6) Rolling OLS hedge ratios

Static OLS assumes one relationship for the whole out-of-sample period. Rolling OLS relaxes that assumption. At each date \(t\), we estimate beta from the previous 252 trading days:

\[ \hat{\boldsymbol{\beta}}_t = \arg\min_{\boldsymbol{\beta}}\sum_{s=t-L+1}^{t}\left(y_s - \alpha_t - \mathbf{x}_s^\top\boldsymbol{\beta}_t\right)^2 \]

with \(L=252\). Each estimate uses the trailing window ending at \(t\), so the coefficient can adapt as the return relationship changes across regimes.

Rolling OLS is useful when the true relationship drifts. But it has two statistical weaknesses:

  1. Sampling noise. A 252-day window can be noisy, especially with multiple hedges.
  2. Window cliffs. When one old observation drops out and one new observation enters, the beta can jump even if the economic relation hasn’t changed much.

That’s why we apply the no-trade band after rebalancing:

\[ \beta^{trade}_t = \begin{cases} \beta^{trade}_{t-1}, & |\beta^{desired}_t - \beta^{trade}_{t-1}| \le b \\ \beta^{desired}_t, & |\beta^{desired}_t - \beta^{trade}_{t-1}| > b \end{cases} \]

We apply the rule to each hedge coefficient, turning the noisy desired beta into the beta path we actually trade.

Show code
band = 0.05

def roll_beta(ret, r):
    z = ret[r.assets].dropna()
    out = pd.DataFrame(np.nan, index=ret.index, columns=r.hedges)
    for i in range(max(win, n_train) - 1, len(z)):
        sample = z.iloc[i - win + 1:i + 1]
        _, b = ols_fit(sample[r.target], sample[r.hedges])
        out.loc[z.index[i], r.hedges] = b
    return out


def band_beta(beta, band=band):
    traded = pd.DataFrame(np.nan, index=beta.index, columns=beta.columns)
    previous = pd.Series(np.nan, index=beta.columns)
    for date, desired in beta.iterrows():
        if previous.notna().sum() == 0:
            previous = desired.astype(float)
        else:
            update = (desired - previous).abs() > band
            previous.loc[update] = desired.loc[update]
        traded.loc[date] = previous
    return traded


books_roll = {}
for r in rel_main:
    beta_desired = roll_beta(ret_m_main, r)
    beta_rebalanced = rebalance_beta(beta_desired, ret_b_main.index)
    beta_traded = band_beta(beta_rebalanced, band=band)
    beta_daily[(r.name, "rolling")] = beta_desired
    books_roll[f"{r.name} | rolling"] = book_result(hedge_book(ret_b_main, r, beta_traded))
    blog[f"{r.name} | rolling"] = {"rel": r.name, "model": "rolling",
                                     "desired": beta_desired, "traded": beta_traded}

bt_roll = {**books_target, **books_roll}
tab_roll = model_table(bt_roll, rel_main, ret_m_main)
display(tab_roll[model_cols].round(4))

fig, axes = plt.subplots(2, 2, figsize=(12, 6.4), sharex=True)
for ax, name in zip(axes.ravel(), ["qqq_spy", "hyg_credit", "nvda_semi", "tsla_disc"]):
    key = f"{name} | rolling"
    blog[key]["desired"].dropna(how="all").plot(ax=ax, lw=1.1)
    ax.set_title(key)
    ax.grid(True, alpha=0.25)
plt.tight_layout()
plt.show()

for key in ["qqq_spy | rolling", "tsla_disc | rolling"]:
    hedge = blog[key]["desired"].columns[0]
    fig, ax = plt.subplots(figsize=(8.8, 3.1))
    blog[key]["desired"][hedge].dropna().plot(ax=ax, label="desired", lw=1.0)
    blog[key]["traded"][hedge].dropna().plot(ax=ax, label="traded", lw=1.3)
    ax.set_title(key)
    ax.legend()
    ax.grid(True, alpha=0.25)
    plt.tight_layout()
plt.show()
relationship vol_red es_red maxdd_diff beta_red turnover_ann cost_drag_ann
0 qqq_spy 0.6488 0.6592 0.1628 0.9773 0.2895 0.0001
1 tlt_ief 0.6078 0.5837 0.2272 0.9963 0.6379 0.0003
2 agg_bnd 0.7013 0.8079 0.1379 0.9974 0.1774 0.0001
3 hyg_credit 0.4942 0.5236 0.0720 0.9729 0.8388 0.0004
4 gdx_gold 0.3674 0.3913 -0.2852 0.9591 1.4500 0.0007
5 nvda_semi 0.4610 0.4825 0.1683 0.9792 2.2263 0.0011
6 amd_semi 0.2941 0.3182 -0.0136 0.9933 3.3270 0.0017
7 aapl_tech 0.4416 0.4681 0.0333 0.9964 3.4614 0.0017
8 msft_tech 0.5259 0.5430 0.1728 0.9811 2.1224 0.0011
9 amzn_disc 0.4059 0.4538 0.1401 0.9926 1.8225 0.0009
10 tsla_disc 0.2380 0.2438 0.0944 0.9924 5.1968 0.0026
11 cost_def 0.2847 0.2836 0.0796 0.9540 0.7929 0.0004

Rolling OLS improves many risk metrics relative to static OLS, but it also raises turnover.

The strongest improvements appear where the relationship genuinely changes through time. HYG credit improves from about 42.0% to 49.4% volatility reduction, and ES reduction rises from 47.4% to 52.4%. That makes sense because HYG’s exposure to LQD, TLT, and SPY changes across credit regimes. In calm markets, HYG behaves more like carry and equity beta. In stress, it becomes more credit-loss sensitive.

GDX/gold also improves from 20.5% to 36.7% volatility reduction, although drawdown is still bad. Dynamic beta helps, but GDX remains a difficult spread because gold miners have nonlinear exposure to gold, rates, equity stress, and operational leverage.

The tradeoff appears in turnover. For stock relationships, rolling OLS can become expensive in trading terms. TSLA/XLY+QQQ reaches annualized turnover above 5.19, and AAPL and AMD are also above 3. Even with only 5 bps cost, this starts to matter. With higher real-world costs or less liquid implementation, rolling OLS could be too active.

We can see the reason in the rolling beta paths. For QQQ/SPY, the desired beta is fairly smooth, and the traded beta only updates occasionally. For TSLA, the desired beta path shifts much more aggressively. The no-trade band helps, but it can’t fully remove the instability when the relationship itself is unstable.

7) Ridge regression for hedge ratios

Next we improve the hedge ratios using ridge regression. It doesn’t make beta time-varying by itself; we still estimate it on rolling windows here. The difference is that ridge penalizes large and unstable coefficients.

The ridge objective is:

\[ \hat{\boldsymbol{\beta}}_{ridge,t} = \arg\min_{\boldsymbol{\beta}} \left[\sum_{s=t-L+1}^{t}\left(y_s - \alpha_t - \mathbf{x}_s^\top\boldsymbol{\beta}\right)^2 + \alpha \sum_{j=1}^{K}\beta_j^2 \right] \]

We center and scale the target and hedge returns before applying the penalty, so the coefficient sizes are comparable. We handle the intercept by centering, so it isn’t penalized. For standardized data, the closed-form solution is:

\[ \hat{\boldsymbol{\beta}}_{ridge} = (X_z^\top X_z + \alpha I)^{-1}X_z^\top y_z \]

Then we convert the standardized coefficients back to return units:

\[ \hat{\beta}_{j} = \hat{\beta}^{(z)}_{j}\frac{\sigma_y}{\sigma_{x_j}} \]

Ridge is especially useful in multi-hedge relationships where hedge assets are correlated with each other. For example, SMH and QQQ both contain growth/technology exposure. XLY and QQQ both contain mega-cap growth components. LQD, TLT, and SPY can all explain parts of HYG during different regimes. In those settings, pure OLS can assign extreme coefficients because it tries to separate correlated explanatory variables too aggressively.

Ridge shrinks the solution toward smaller coefficients. It accepts a little bias in exchange for lower variance and better stability.

Show code
alpha = 10.0

def ridge_beta(ret, r):
    z = ret[r.assets].dropna()
    out = pd.DataFrame(np.nan, index=ret.index, columns=r.hedges)
    for i in range(max(win, n_train) - 1, len(z)):
        sample = z.iloc[i - win + 1:i + 1]
        x = sample[r.hedges].to_numpy(float)
        y = sample[r.target].to_numpy(float)
        xm = x.mean(axis=0, keepdims=True)
        ym = y.mean()
        xs = x.std(axis=0, ddof=1)
        ys = y.std(ddof=1)
        xz = (x - xm) / xs
        yz = (y - ym) / ys
        b_std = np.linalg.pinv(xz.T @ xz + alpha * np.eye(xz.shape[1])) @ (xz.T @ yz)
        out.loc[z.index[i], r.hedges] = b_std * ys / xs
    return out


books_ridge = {}
for r in rel_main:
    beta_desired = ridge_beta(ret_m_main, r)
    beta_rebalanced = rebalance_beta(beta_desired, ret_b_main.index)
    beta_traded = band_beta(beta_rebalanced, band=band)
    beta_daily[(r.name, "ridge")] = beta_desired
    books_ridge[f"{r.name} | ridge"] = book_result(hedge_book(ret_b_main, r, beta_traded))
    blog[f"{r.name} | ridge"] = {"rel": r.name, "model": "ridge",
                                     "desired": beta_desired, "traded": beta_traded}

bt_ridge = {**books_target, **books_ridge}
tab_ridge = model_table(bt_ridge, rel_main, ret_m_main)
display(tab_ridge[model_cols].round(4))

fig, axes = plt.subplots(1, 2, figsize=(12, 3.4), sharex=True)
for ax, name in zip(axes, ["hyg_credit", "nvda_semi"]):
    key = f"{name} | ridge"
    blog[key]["desired"].dropna(how="all").plot(ax=ax, lw=1.1)
    ax.set_title(key)
    ax.grid(True, alpha=0.25)
plt.tight_layout()
plt.show()

turn_bar = (pd.concat([tab_roll.assign(model="rolling"), tab_ridge.assign(model="ridge")])
            .groupby("model")["turnover_ann"].mean())
fig, ax = plt.subplots(figsize=(6.5, 3.1))
ax.bar(turn_bar.index, turn_bar.values, color=colors[:len(turn_bar)])
ax.set_title("average annual turnover: rolling vs ridge")
ax.grid(True, axis="y", alpha=0.25)
plt.tight_layout()
plt.show()
relationship vol_red es_red maxdd_diff beta_red turnover_ann cost_drag_ann
0 qqq_spy 0.6491 0.6586 0.1652 0.9835 0.2597 0.0001
1 tlt_ief 0.6066 0.5834 0.2291 0.9657 0.6048 0.0003
2 agg_bnd 0.6910 0.7960 0.1365 0.9694 0.1652 0.0001
3 hyg_credit 0.4870 0.5163 0.0798 0.9339 0.6489 0.0003
4 gdx_gold 0.3689 0.3923 -0.2580 0.9972 1.3368 0.0007
5 nvda_semi 0.4613 0.4859 0.1700 0.9976 1.5870 0.0008
6 amd_semi 0.2955 0.3219 0.0050 0.9871 2.4303 0.0012
7 aapl_tech 0.4397 0.4676 0.0575 0.9754 1.1579 0.0006
8 msft_tech 0.5268 0.5473 0.1770 0.9921 0.6577 0.0003
9 amzn_disc 0.4048 0.4525 0.1300 0.9723 1.3917 0.0007
10 tsla_disc 0.2408 0.2485 0.0948 0.9746 3.8495 0.0019
11 cost_def 0.2857 0.2854 0.0843 0.9755 0.7401 0.0004

The ridge results are close to rolling OLS on volatility and ES reduction, but turnover is much lower.

In the turnover comparison, we can see that average annual turnover falls from about 1.86 for rolling OLS at the 5% band to about 1.24 for ridge. The risk reduction remains close to rolling OLS, while the ridge coefficient path requires fewer changes in the traded positions. That makes the difference visible in both turnover and the resulting cost drag.

A few relationship-level details stand out:

  • QQQ/SPY gets about 64.9% volatility reduction, almost identical to rolling OLS, with slightly lower turnover.
  • TLT/IEF keeps the strong hedge quality, with around 60.7% volatility reduction and the best drawdown improvement in that block.
  • GDX/gold improves a lot relative to static OLS, but the drawdown problem remains. Ridge can’t remove economic model risk. It only stabilizes coefficients.
  • NVDA/SMH+QQQ and MSFT/XLK+QQQ are strong examples where ridge keeps high risk reduction while controlling turnover relative to rolling OLS.

For these relationships, shrinkage can retain much of the risk reduction while producing a more stable coefficient path. A slightly biased estimate may therefore be useful when it avoids frequent position changes and the costs that come with them.

7.1 Bias-variance tradeoff in hedge estimation

The difference between rolling OLS and ridge is a classic bias-variance tradeoff.

Rolling OLS is unbiased under the usual regression assumptions, but it can have high variance when hedge variables overlap. Ridge adds shrinkage, so the coefficients are biased toward zero, but the estimator becomes more stable:

\[ \text{MSE}(\hat{\beta}) = \text{Bias}(\hat{\beta})^2 + \text{Var}(\hat{\beta}) \]

A hedge ratio is a trading input, so lower estimator variance can be more valuable than zero bias. A slightly biased hedge that trades smoothly can be better than a theoretically unbiased hedge that jumps around and creates cost.

This is especially true in multi-hedge single-stock models. If SMH and QQQ both explain NVDA, OLS may fight over which hedge gets credit. Ridge softens that fight. It doesn’t remove the economic overlap, but it prevents the regression from translating that overlap into extreme coefficient changes.

8) Kalman filter hedge ratios

We now move to the Kalman filter to estimate a hedge ratio that changes as new return observations arrive. With static OLS we use one hedge ratio, and with rolling OLS we re-estimate it on a fixed window. Here we treat the hedge ratio as a hidden state that moves through time and can only be inferred indirectly from the observed returns. We also keep track of the uncertainty around that state estimate as new returns arrive.

The changing beta between two assets isn’t directly visible. We observe daily target and hedge returns, then infer the coefficient that relates them. A surprise in the target return can come from a change in the relationship or from a temporary shock, so we need to account for uncertainty when deciding how much to update beta. A slowly changing relationship should produce a gradual adjustment, while a noisy daily move shouldn’t force a large change by itself.

We can use the Kalman filter to handle this problem in a sequence of steps:

  • it keeps a current belief about the hedge ratio,
  • it predicts what today’s target return should be given that belief,
  • it measures the surprise between the predicted and realized target return,
  • it updates the hedge ratio only as much as the surprise deserves.

This makes it more structured than rolling OLS. Rolling OLS has a hard memory cutoff. The Kalman filter has a soft memory: old information fades because uncertainty accumulates, but it isn’t suddenly dropped from the model.

8.1 The state-space model

To estimate the changing hedge, we write the problem as a state-space model with two equations.

The first equation connects the hidden hedge ratio to the observed target return. This is the observation equation:

\[ y_t = \alpha_t + \mathbf{x}_t^\top\boldsymbol{\beta}_t + \varepsilon_t \]

where:

  • \(y_t\) is the target return, for example NVDA’s daily return,
  • \(\mathbf{x}_t\) is the vector of hedge returns, for example SMH and QQQ returns,
  • \(\boldsymbol{\beta}_t\) is the vector of hedge ratios at time \(t\),
  • \(\alpha_t\) is the time-varying intercept,
  • \(\varepsilon_t\) is the return shock the hedge basket didn’t explain.

We assume the unexplained return shock is Gaussian noise:

\[ \varepsilon_t \sim \mathcal{N}(0, R) \]

The scalar \(R\) is observation noise. If \(R\) is large, daily returns are very noisy relative to the regression relationship. In that case, one bad prediction doesn’t prove beta changed. If \(R\) is small, a forecast error is more informative, so beta can update more.

The second equation describes how the hidden state moves. We collect the intercept and all hedge ratios into one vector:

\[ \boldsymbol{\theta}_t = \begin{bmatrix} \alpha_t & \beta_{1,t} & \cdots & \beta_{K,t} \end{bmatrix}^\top \]

If this state follows a random walk, its movement is:

\[ \boldsymbol{\theta}_t = \boldsymbol{\theta}_{t-1} + \boldsymbol{\eta}_t \]

with:

\[ \boldsymbol{\eta}_t \sim \mathcal{N}(0, Q) \]

We use the matrix \(Q\) for process noise, which controls how much the hedge ratios are allowed to move from one day to the next. High \(Q\) means we believe the relationship is unstable and beta can move quickly. Low \(Q\) means we believe the relationship is stable and beta should move slowly.

\(R\) is noise in returns. \(Q\) is noise in the hidden beta path. The whole Kalman filter is basically a disciplined balance between these two uncertainties.

8.2 Prediction step: belief before seeing today’s return

At the end of yesterday, we have the filter’s state estimate and its uncertainty:

\[ \boldsymbol{\theta}_{t-1|t-1}, \qquad P_{t-1|t-1} \]

The notation \(t-1|t-1\) means “our estimate at time \(t-1\) after using information available through time \(t-1\).”

  • \(\boldsymbol{\theta}_{t-1|t-1}\) is yesterday’s best estimate of the state, meaning yesterday’s intercept and hedge ratios.
  • \(P_{t-1|t-1}\) is the uncertainty around that estimate. It’s a covariance matrix, so the diagonal entries measure uncertainty around each state component, and the off-diagonal entries measure how those uncertainties move together.

Before seeing today’s return, we predict today’s state. Since the state follows a random walk, the best prediction for today’s beta is yesterday’s beta:

\[ \boldsymbol{\theta}_{t|t-1} = \boldsymbol{\theta}_{t-1|t-1} \]

The notation \(t|t-1\) means “our estimate for time \(t\) before seeing time \(t\) data.” Under the random-walk assumption, the expected change in the state is zero before the new observation arrives. The actual hidden beta can still move, and the uncertainty matrix accounts for that possible movement.

But our uncertainty does increase:

\[ P_{t|t-1} = P_{t-1|t-1} + Q \]

Our predicted beta remains at yesterday’s estimate, while its uncertainty increases because another day has passed and the hidden relationship could have drifted. The process-noise matrix \(Q\) adds precisely that extra uncertainty to the prediction covariance.

For TLT and IEF, we can carry yesterday’s estimate forward as the starting point for today’s duration hedge. Rates, duration risk, and bond volatility can still change between observations, so the added process uncertainty allows the next return observation to revise that estimate.

8.3 Measurement step: prediction error and its uncertainty

Now we observe today’s hedge returns and target return. To write the regression compactly, we create the design vector:

\[ \mathbf{h}_t = \begin{bmatrix}1 & x_{1,t} & \cdots & x_{K,t}\end{bmatrix}^\top \]

The first element is \(1\) because the state includes an intercept. The remaining elements are today’s hedge returns.

Using the predicted state and today’s observed hedge returns, we can estimate today’s target return:

\[ \hat{y}_{t|t-1} = \mathbf{h}_t^\top\boldsymbol{\theta}_{t|t-1} \]

This is simply the regression prediction using the current beta belief. If the target is HYG and the hedges are LQD, TLT, and SPY, this predicted return is the return HYG “should” have earned based on today’s credit, rate, and equity moves.

Then we compare prediction with reality:

\[ v_t = y_t - \hat{y}_{t|t-1} \]

This \(v_t\) is called the innovation. It’s the new information in today’s observation. If \(v_t\) is near zero, the old beta explained today’s return well. If \(v_t\) is large, something was missed: either beta changed, the target had an idiosyncratic shock, or the observation is noisy.

To judge how surprising that innovation is, we also need its variance. This is the innovation variance:

\[ S_t = \mathbf{h}_t^\top P_{t|t-1}\mathbf{h}_t + R \]

This equation has two parts:

  • \(\mathbf{h}_t^\top P_{t|t-1}\mathbf{h}_t\) is uncertainty coming from our uncertain state estimate. If beta is poorly known, the prediction is uncertain.
  • \(R\) is observation noise. Even with a perfect beta estimate, daily returns still contain noise.

So \(S_t\) answers: how much prediction error should we expect before calling today’s move a real beta signal? A large \(S_t\) means we expected noisy prediction errors, so a given \(v_t\) isn’t too informative. A small \(S_t\) means the prediction should have been accurate, so the same \(v_t\) matters more.

8.4 Kalman gain and state update

We use the Kalman gain to determine how much the hedge ratio should move after seeing the innovation:

\[ K_t = \frac{P_{t|t-1}\mathbf{h}_t}{S_t} \]

Here \(K_t\) is a vector with one entry for each state component: one for the intercept and one for each hedge ratio. It works like an adaptive learning rate.

The numerator \(P_{t|t-1}\mathbf{h}_t\) says how much uncertainty in each state component matters for today’s prediction. If the SPY hedge coefficient is very uncertain and today’s SPY return is large, the SPY beta component receives more attention. If a hedge return is tiny today, there isn’t much information about that hedge’s beta, even if the prediction error is large.

The denominator \(S_t\) scales the update by total prediction uncertainty. If today’s prediction was expected to be noisy, we update the state less. If today’s prediction was supposed to be precise, we update the state more.

The state update is:

\[ \boldsymbol{\theta}_{t|t} = \boldsymbol{\theta}_{t|t-1} + K_t v_t \]

Written in words, the state update combines our previous estimate with the new information in today’s return:

\[ \text{new beta belief} = \text{old beta belief} + \text{learning rate} \times \text{forecast surprise} \]

If the target return is higher than predicted, \(v_t > 0\). Depending on the signs and magnitudes inside \(K_t\), the update may raise one or more of the hedge ratios. If the target return is lower than predicted, \(v_t < 0\), the update goes in the opposite direction.

After updating the state, we also update its uncertainty:

\[ P_{t|t} = (I - K_t\mathbf{h}_t^\top)P_{t|t-1} \]

The identity matrix \(I\) keeps dimensions correct. The term \(K_t\mathbf{h}_t^\top\) represents how much information today’s observation gave us about the state. After using a useful observation, we’re more confident than before, so uncertainty falls.

The full cycle is therefore:

  1. carry yesterday’s hedge ratio forward,
  2. add uncertainty because the true beta may have moved,
  3. predict today’s target return,
  4. measure the forecast surprise,
  5. decide how informative the surprise is,
  6. update the hedge ratio,
  7. reduce uncertainty after learning from the observation.

The updated state carries information from earlier observations into the next date. Each new observation is weighted by its innovation and uncertainty, so the hedge path can adjust without assigning the same response to every daily return surprise.

8.5 Likelihood selection for \(Q\) and \(R\)

We need values for \(Q\) and \(R\), and choosing them casually would make the result arbitrary. So we select them from the training window using likelihood.

The likelihood measures how plausible the observed forecast errors are under a candidate pair of \(Q\) and \(R\). For each observation, the Gaussian log-likelihood contribution is:

\[ \ell_t = -\frac{1}{2}\left[\log(2\pi S_t) + \frac{v_t^2}{S_t}\right] \]

where:

  • \(v_t\) is the innovation, the one-step-ahead forecast error,
  • \(S_t\) is the innovation variance,
  • \(\log(2\pi S_t)\) penalizes models that claim too much uncertainty,
  • \(v_t^2/S_t\) penalizes models that produce forecast errors too large relative to their claimed uncertainty.

The total likelihood over the training sample is:

\[ \mathcal{L} = \sum_{t=1}^{T}\ell_t \]

We try several process-noise and observation-noise settings, then choose the combination with the best penalized likelihood on the training window. The penalty matters because a very large \(Q\) can make beta chase the target too closely. That may improve in-sample forecast errors, but it creates unstable hedge ratios and high turnover.

We want the chosen \(Q\) and \(R\) to let the hedge follow genuine changes in the relationship without treating every noisy daily return as a change in beta. The training-window likelihood gives us a way to select that balance.

8.6 Kalman filter versus rolling windows

The two estimators use past observations differently when they update the hedge ratio.

Rolling OLS has a hard memory cutoff:

\[ \mathcal{W}_t = \{t-251,\ldots,t\} \]

Everything inside the window is used equally, and everything outside the window is ignored completely. This makes the estimator easy to understand, but it creates artificial jumps when important observations enter or leave the window.

The Kalman filter has soft memory. Past data affects the current state through the previous posterior estimate and its uncertainty. If we’re confident in the current state, one new observation doesn’t move beta much. If our estimate is uncertain, the same observation can move beta more.

Rolling OLS refits on a fixed set of recent observations, while the Kalman estimate carries the previous state forward and updates it using the new observation. The resulting Kalman path can be smoother and trade less, although the response also depends on the chosen process and observation noise. Rolling OLS may react faster when the underlying relationship changes abruptly.

Show code
def kf_run(y, x, state0, pcov0, qdiag, obs_var):
    state = state0.copy()
    pcov = pcov0.copy()
    qcov = np.diag(qdiag)
    states, loglik = [], 0.0
    for yy, xx in zip(y, x):
        h = np.r_[1.0, xx]
        pcov = pcov + qcov
        pred = float(h @ state)
        s = float(h @ pcov @ h.T + obs_var)
        err = float(yy - pred)
        loglik += -0.5 * (np.log(2 * np.pi * s) + err * err / s)
        k = (pcov @ h.T) / s
        state = state + k * err
        pcov = (np.eye(len(state)) - np.outer(k, h)) @ pcov
        states.append(state.copy())
    return np.asarray(states), loglik


def kf_beta(ret, r):
    z = ret[r.assets].dropna()
    out = pd.DataFrame(np.nan, index=ret.index, columns=r.hedges)
    train = z.iloc[:n_train]
    y_train = train[r.target].to_numpy(float)
    x_train = train[r.hedges].to_numpy(float)
    init_n = max(min(n_train // 4, 126), 30)
    a0, b0 = ols_fit(y_train[:init_n], x_train[:init_n])
    state0 = np.r_[a0, b0]
    resid = y_train - np.column_stack([np.ones(len(train)), x_train]) @ state0
    resid_var = max(np.nanvar(resid, ddof=1), 1e-8)
    pcov0 = np.eye(len(state0)) * max(resid_var, 1e-6)

    scale = np.r_[0.05, np.maximum(np.abs(b0), 0.25)]
    best_ll, best_q, best_r = -np.inf, np.r_[1e-7, np.repeat(1e-5, len(b0))], resid_var
    for q0 in [1e-7, 3e-7, 1e-6, 3e-6, 1e-5, 3e-5, 1e-4]:
        qdiag = np.maximum(q0 * scale**2, 1e-12)
        for r0 in [0.5, 1.0, 2.0, 4.0]:
            obs_var = max(resid_var * r0, 1e-10)
            _, ll = kf_run(y_train, x_train, state0, pcov0, qdiag, obs_var)
            ll -= 0.5 * qdiag[1:].sum() / obs_var
            if ll > best_ll:
                best_ll, best_q, best_r = ll, qdiag, obs_var

    y_all = z[r.target].to_numpy(float)
    x_all = z[r.hedges].to_numpy(float)
    states, _ = kf_run(y_all, x_all, state0, pcov0, best_q, best_r)
    out.loc[z.index, r.hedges] = states[:, 1:]
    out.loc[z.index[:n_train - 1], r.hedges] = np.nan
    return out


books_kalman = {}
for r in rel_main:
    beta_desired = kf_beta(ret_m_main, r)
    beta_rebalanced = rebalance_beta(beta_desired, ret_b_main.index)
    beta_traded = band_beta(beta_rebalanced, band=band)
    beta_daily[(r.name, "kalman")] = beta_desired
    books_kalman[f"{r.name} | kalman"] = book_result(hedge_book(ret_b_main, r, beta_traded))
    blog[f"{r.name} | kalman"] = {"rel": r.name, "model": "kalman",
                                     "desired": beta_desired, "traded": beta_traded}

bt_kalman = {**books_target, **books_kalman}
tab_kalman = model_table(bt_kalman, rel_main, ret_m_main)
display(tab_kalman[model_cols].round(4))

fig, axes = plt.subplots(2, 2, figsize=(12, 6.4), sharex=True)
for ax, name in zip(axes.ravel(), ["qqq_spy", "tlt_ief", "nvda_semi", "hyg_credit"]):
    key = f"{name} | kalman"
    blog[key]["desired"].dropna(how="all").plot(ax=ax, lw=1.1)
    ax.set_title(key)
    ax.grid(True, alpha=0.25)
plt.tight_layout()
plt.show()
relationship vol_red es_red maxdd_diff beta_red turnover_ann cost_drag_ann
0 qqq_spy 0.6392 0.6480 0.1560 0.9537 0.1424 0.0001
1 tlt_ief 0.5902 0.5486 0.2172 0.9354 0.2941 0.0001
2 agg_bnd 0.6873 0.7880 0.1381 0.9936 0.1532 0.0001
3 hyg_credit 0.4866 0.5234 0.0531 0.9651 0.2450 0.0001
4 gdx_gold 0.3685 0.3884 -0.2578 0.9668 1.2393 0.0006
5 nvda_semi 0.4559 0.4810 0.1631 0.9955 0.3317 0.0002
6 amd_semi 0.2970 0.3185 -0.0112 0.9941 0.3310 0.0002
7 aapl_tech 0.4028 0.4297 -0.0509 0.8475 0.2880 0.0001
8 msft_tech 0.5283 0.5442 0.1731 0.9743 0.3139 0.0002
9 amzn_disc 0.3833 0.4342 0.0915 0.9903 0.1929 0.0001
10 tsla_disc 0.2350 0.2454 0.0927 0.9342 0.5837 0.0003
11 cost_def 0.2778 0.2667 0.0701 0.8560 0.0942 0.0000

With the Kalman estimates, we can see the smoother behavior in the hedge paths.

Risk reduction is strong across most relationships, but turnover is much lower than rolling OLS and ridge in many of the noisy stock relationships. For example:

  • NVDA/SMH+QQQ gets about 45.6% volatility reduction with annualized turnover around 0.33, compared with ridge turnover around 1.59 and rolling turnover above 2.22.
  • AMD/SMH+QQQ has turnover around 0.33, much lower than ridge and rolling, while keeping similar volatility reduction.
  • HYG credit gets nearly the same ES reduction as rolling OLS, but with much lower turnover.
  • QQQ/SPY is stable under all methods, so Kalman doesn’t need to win there. Its advantage is more about smooth implementation than maximum score.

Looking at the Kalman beta paths, we can see how smoothly the estimates change. QQQ/SPY gradually rises through the sample. TLT/IEF shifts sharply around the rate shock period and then compresses. HYG credit shows changing weights across LQD, TLT, and SPY, which fits the economics of credit: high yield alternates between behaving like credit carry, duration exposure, and equity risk.

The filter models beta as an evolving latent state, and its estimated noise levels control how quickly the position can change. A smooth path helps limit trading, although it can also adjust too slowly when a relationship changes abruptly. We can compare that tradeoff with the other estimators in the model scores.

9) No-trade band sensitivity

After estimating a desired beta path, we decide how aggressively to trade it. We use the no-trade band to control these updates. If the band is zero, every weekly beta change is traded. If the band is wider, small beta changes are ignored.

We test:

\[ b \in \{0.00, 0.025, 0.05, 0.075, 0.10\} \]

For each band, we recompute the hedge books for rolling, ridge, and Kalman models, then evaluate average risk reduction, turnover, cost, and a composite score.

The composite score is intentionally multi-objective. It rewards:

  • higher volatility reduction,
  • higher ES reduction,
  • better drawdown improvement,
  • stronger beta reduction,
  • more stable beta paths,

and penalizes:

  • turnover,
  • cost drag,
  • negative drawdown effects.

A pure volatility score would be too narrow. A hedge model can reduce variance by taking an unstable residual exposure that creates drawdown or trading costs. Including drawdown and trading costs lets us compare those risks along with the reduction in variance.

Show code
def normalize_score(s, lower=False):
    x = pd.to_numeric(s, errors="coerce").replace([np.inf, -np.inf], np.nan)
    if x.notna().sum() <= 1:
        out = pd.Series(0.5, index=s.index)
    else:
        lo, hi = x.quantile(0.05), x.quantile(0.95)
        xc = x.clip(lo, hi)
        span = xc.max() - xc.min()
        out = pd.Series(0.5, index=s.index) if span <= 1e-12 else (xc - xc.min()) / span
    return (1 - out if lower else out).fillna(0.5)


def score_table(tab):
    parts = []
    for _, g in tab.groupby("relationship", sort=False):
        h = g.copy()
        vol_s = normalize_score(h["vol_red"])
        dd_bad = ((-pd.to_numeric(h["maxdd_diff"], errors="coerce"))
                  .clip(lower=0.0).div(0.10).clip(upper=1.0).fillna(0.0))
        vol_s = vol_s.mask(h["maxdd_diff"] < -0.05, np.minimum(vol_s, 0.50))
        h["score"] = (0.30 * vol_s + 0.20 * normalize_score(h["es_red"])
                      + 0.15 * normalize_score(h["maxdd_diff"]) + 0.15 * normalize_score(h["beta_red"])
                      + 0.10 * normalize_score(h["beta_stability"])
                      - 0.05 * (1 - normalize_score(h["turnover_ann"], lower=True))
                      - 0.05 * (1 - normalize_score(h["cost_drag_ann"], lower=True)) - 0.10 * dd_bad)
        parts.append(h)
    return pd.concat(parts, ignore_index=True) if parts else pd.DataFrame()


band_grid = [0.00, 0.025, 0.05, 0.075, 0.10]
dyn_models = ["rolling", "ridge", "kalman"]
sens_rows = []

for bnd in band_grid:
    books_sens = dict(books_target)
    for r in rel_main:
        for m in dyn_models:
            beta_rebalanced = rebalance_beta(beta_daily[(r.name, m)], ret_b_main.index)
            beta_traded = band_beta(beta_rebalanced, band=bnd)
            books_sens[f"{r.name} | {m}"] = book_result(hedge_book(ret_b_main, r, beta_traded))
    tab_sens = model_table(books_sens, rel_main, ret_m_main)
    for m, g in score_table(tab_sens).groupby("model"):
        sens_rows.append({"model": m, "band": bnd, "avg_vol_red": g["vol_red"].mean(),
                          "avg_es_red": g["es_red"].mean(), "avg_turnover_ann": g["turnover_ann"].mean(),
                          "avg_cost_drag_ann": g["cost_drag_ann"].mean(), "avg_score": g["score"].mean()})

tab_band = pd.DataFrame(sens_rows)
display(tab_band.round(4))

fig, axes = plt.subplots(1, 2, figsize=(11.5, 3.5), sharex=True)
for m, g in tab_band.groupby("model"):
    axes[0].plot(g["band"], g["avg_turnover_ann"], marker="o", label=m)
    axes[1].plot(g["band"], g["avg_score"], marker="o", label=m)
axes[0].set_title("annual turnover vs band")
axes[1].set_title("score vs band")
for ax in axes:
    ax.grid(True, alpha=0.25)
    ax.set_xlabel("band")
axes[0].legend(fontsize=8)
plt.tight_layout()
plt.show()
model band avg_vol_red avg_es_red avg_turnover_ann avg_cost_drag_ann avg_score
0 kalman 0.000 0.4501 0.4719 0.5335 0.0003 0.3277
1 ridge 0.000 0.4568 0.4816 1.8476 0.0009 0.5336
2 rolling 0.000 0.4566 0.4812 2.5414 0.0013 0.3320
3 kalman 0.025 0.4481 0.4700 0.3931 0.0002 0.3118
4 ridge 0.025 0.4563 0.4807 1.4766 0.0007 0.5592
5 rolling 0.025 0.4562 0.4807 2.1618 0.0011 0.3695
6 kalman 0.050 0.4460 0.4680 0.3508 0.0002 0.2542
7 ridge 0.050 0.4548 0.4796 1.2358 0.0006 0.5659
8 rolling 0.050 0.4559 0.4799 1.8619 0.0009 0.4351
9 kalman 0.075 0.4454 0.4677 0.3155 0.0002 0.2837
10 ridge 0.075 0.4541 0.4780 1.1076 0.0006 0.5537
11 rolling 0.075 0.4543 0.4779 1.6460 0.0008 0.3969
12 kalman 0.100 0.4434 0.4647 0.2917 0.0001 0.2599
13 ridge 0.100 0.4523 0.4766 1.0123 0.0005 0.5549
14 rolling 0.100 0.4523 0.4754 1.4877 0.0007 0.3939

When we compare the no-trade bands, we can see a clear difference in the amount each estimator trades.

Kalman has the lowest turnover at every band. At band zero, its average annual turnover is only about 0.53, while ridge is around 1.85 and rolling is around 2.54. This confirms the state-space model is already smoothing the beta path before the no-trade band is applied.

Ridge has the highest average score across the tested bands. At the 5% band, ridge reaches the best average score, around 0.566, with average volatility reduction around 45.5% and average ES reduction around 48.0%. Rolling OLS has similar risk reduction but higher turnover, so its score is lower.

Across the tested bands, we can compare the tradeoff directly:

  • turnover falls as the band gets wider,
  • score for ridge improves up to about the 5% band and then stays near the top,
  • rolling benefits from a band because raw rolling beta is too jumpy,
  • Kalman doesn’t benefit much from wider bands because it already trades slowly.

So the 5% band is a reasonable choice. It removes many small noisy updates without freezing the hedge ratio too much.

10) Model scoring and best hedge selection

Now we compare the static, rolling, ridge, and Kalman results. We score the models within each relationship, so the same metric is compared among models applied to the same target and hedge basket.

A 40% volatility reduction in TSLA isn’t the same difficulty as a 40% reduction in AGG. AGG/BND is a clean ETF replication problem. TSLA/XLY+QQQ is a much harder idiosyncratic stock problem. Scoring within each relationship avoids unfairly rewarding easy pairs only because they are easy.

For relationship \(i\) and model \(m\), we normalize the components within the relationship and calculate the score using:

\[ \text{score}_{i,m} = 0.30S_{vol} + 0.20S_{ES} + 0.15S_{DD} + 0.15S_{\beta} + 0.10S_{stable} - 0.05P_{turnover} - 0.05P_{cost} - 0.10P_{badDD} \]

These weights define the ranking used in our comparison: we give most weight to volatility and tail-risk reduction while also accounting for drawdown, beta stability, and trading. Our aim is risk reduction with stable, tradable hedge ratios.

The two most important additional diagnostics are:

\[ \text{beta stability} = \frac{1}{1 + \text{IQR}(\beta) + 5\cdot \text{mean}|\Delta \beta|} \]

and:

\[ \text{score gap} = \text{best score} - \text{second best score} \]

A large score gap means one model clearly dominates for that relationship. A small score gap means the choice is less certain.

Show code
def best_table(tab):
    idx = tab.groupby("relationship")["score"].idxmax()
    cols = ["relationship", "model", "score", "vol_red", "es_red", "maxdd_diff", "cost_drag_ann"]
    return tab.loc[idx, cols].rename(columns={"model": "best_model"}).reset_index(drop=True)


def robust_table(tab):
    wins = best_table(tab)["best_model"].value_counts() if len(tab) else pd.Series(dtype=int)
    rows = []
    for m, g in tab.groupby("model"):
        rows.append({"model": m, "median_score": g["score"].median(),
                     "median_vol_red": g["vol_red"].median(), "median_es_red": g["es_red"].median(),
                     "median_cost_drag_ann": g["cost_drag_ann"].median(), "win_count": int(wins.get(m, 0)),
                     "failure_count": int(((g["vol_red"] <= 0) | (g["score"] <= 0)).sum())})
    return pd.DataFrame(rows).sort_values(["median_score", "win_count"], ascending=False)
Show code
books_all = {}
books_all.update(books_target)
books_all.update(books_static)
books_all.update(books_roll)
books_all.update(books_ridge)
books_all.update(books_kalman)

bt_all = books_all
tab_model = model_table(bt_all, rel_main, ret_m_main)
tab_score = score_table(tab_model)
tab_best = best_table(tab_score)
tab_robust = robust_table(tab_score)

score_cols = ["relationship", "model", "vol_red", "es_red", "maxdd_diff", "beta_red",
              "turnover_ann", "cost_drag_ann", "beta_stability", "rebalance_count"]
display(tab_score[score_cols].round(4).head(20))

fig, axes = plt.subplots(1, 3, figsize=(16, 4.8))
best_plot = tab_best.sort_values("score").copy()
model_order = list(best_plot["best_model"].dropna().unique())
model_colors = {m: colors[i % len(colors)] for i, m in enumerate(model_order)}
axes[0].barh(best_plot["relationship"], best_plot["score"],
             color=[model_colors[m] for m in best_plot["best_model"]], alpha=0.88)
axes[0].set_title("best hedge score")
axes[0].set_xlabel("score")
axes[0].grid(True, axis="x", alpha=0.25)

counts = tab_best["best_model"].value_counts().sort_values()
axes[1].barh(counts.index, counts.values, color=[colors[i % len(colors)] for i in range(len(counts))], alpha=0.88)
axes[1].set_title("best model counts")
axes[1].set_xlabel("relationships")
axes[1].grid(True, axis="x", alpha=0.25)

gaps = {}
for name, g in tab_score.groupby("relationship"):
    vals = g["score"].dropna().sort_values(ascending=False).values
    gaps[name] = vals[0] - vals[1] if len(vals) > 1 else np.nan
gap_series = pd.Series(gaps).sort_values()
axes[2].barh(gap_series.index, gap_series.values,
             color=[colors[(i + 2) % len(colors)] for i in range(len(gap_series))], alpha=0.88)
axes[2].set_title("best-minus-second score gap")
axes[2].set_xlabel("score gap")
axes[2].grid(True, axis="x", alpha=0.25)
plt.tight_layout()
plt.show()

tab_quality = tab_model.groupby("relationship").agg(
    best_vol_red=("vol_red", "max"), median_vol_red=("vol_red", "median"),
    best_es_red=("es_red", "max"), best_maxdd_diff=("maxdd_diff", "max"),
    median_cost_drag_ann=("cost_drag_ann", "median")).reset_index()
display(tab_best.round(4))
display(tab_robust.round(4))
display(tab_quality.round(4))
relationship model vol_red es_red maxdd_diff beta_red turnover_ann cost_drag_ann beta_stability rebalance_count
0 qqq_spy static 0.6417 0.6509 0.1496 0.9547 0.1236 0.0001 1.0000 1
1 qqq_spy rolling 0.6488 0.6592 0.1628 0.9773 0.2895 0.0001 0.9133 25
2 qqq_spy ridge 0.6491 0.6586 0.1652 0.9835 0.2597 0.0001 0.9156 21
3 qqq_spy kalman 0.6392 0.6480 0.1560 0.9537 0.1424 0.0001 0.9531 5
4 tlt_ief static 0.5837 0.5382 0.1944 0.9163 0.2484 0.0001 1.0000 1
5 tlt_ief rolling 0.6078 0.5837 0.2272 0.9963 0.6379 0.0003 0.7848 54
6 tlt_ief ridge 0.6066 0.5834 0.2291 0.9657 0.6048 0.0003 0.7909 53
7 tlt_ief kalman 0.5902 0.5486 0.2172 0.9354 0.2941 0.0001 0.8085 8
8 agg_bnd static 0.7153 0.8112 0.1456 0.9857 0.1061 0.0001 1.0000 1
9 agg_bnd rolling 0.7013 0.8079 0.1379 0.9974 0.1774 0.0001 0.9476 8
10 agg_bnd ridge 0.6910 0.7960 0.1365 0.9694 0.1652 0.0001 0.9951 6
11 agg_bnd kalman 0.6873 0.7880 0.1381 0.9936 0.1532 0.0001 0.9100 5
12 hyg_credit static 0.4197 0.4741 0.0097 0.8018 0.1523 0.0001 1.0000 1
13 hyg_credit rolling 0.4942 0.5236 0.0720 0.9729 0.8388 0.0004 0.8880 64
14 hyg_credit ridge 0.4870 0.5163 0.0798 0.9339 0.6489 0.0003 0.8906 47
15 hyg_credit kalman 0.4866 0.5234 0.0531 0.9651 0.2450 0.0001 0.9509 16
16 gdx_gold static 0.2050 0.2059 -0.4645 0.3172 0.3950 0.0002 1.0000 1
17 gdx_gold rolling 0.3674 0.3913 -0.2852 0.9591 1.4500 0.0007 0.7736 104
18 gdx_gold ridge 0.3689 0.3923 -0.2580 0.9972 1.3368 0.0007 0.7808 96
19 gdx_gold kalman 0.3685 0.3884 -0.2578 0.9668 1.2393 0.0006 0.8161 100

relationship best_model score vol_red es_red maxdd_diff cost_drag_ann
0 aapl_tech ridge 0.7829 0.4397 0.4676 0.0575 0.0006
1 agg_bnd static 0.8328 0.7153 0.8112 0.1456 0.0001
2 amd_semi kalman 0.7801 0.2970 0.3185 -0.0112 0.0002
3 amzn_disc rolling 0.7000 0.4059 0.4538 0.1401 0.0009
4 cost_def ridge 0.7187 0.2857 0.2854 0.0843 0.0004
5 gdx_gold kalman 0.4807 0.3685 0.3884 -0.2578 0.0006
6 hyg_credit kalman 0.7479 0.4866 0.5234 0.0531 0.0001
7 msft_tech kalman 0.8533 0.5283 0.5442 0.1731 0.0002
8 nvda_semi ridge 0.7379 0.4613 0.4859 0.1700 0.0008
9 qqq_spy ridge 0.7087 0.6491 0.6586 0.1652 0.0001
10 tlt_ief rolling 0.6924 0.6078 0.5837 0.2272 0.0003
11 tsla_disc kalman 0.6207 0.2350 0.2454 0.0927 0.0003
model median_score median_vol_red median_es_red median_cost_drag_ann win_count failure_count
1 ridge 0.6413 0.4505 0.4767 0.0005 4 0
2 rolling 0.6252 0.4513 0.4753 0.0008 2 0
0 kalman 0.4382 0.4294 0.4576 0.0001 5 0
3 static 0.1168 0.4084 0.4394 0.0001 1 1
relationship best_vol_red median_vol_red best_es_red best_maxdd_diff median_cost_drag_ann
0 aapl_tech 0.4416 0.4213 0.4681 0.0575 0.0004
1 agg_bnd 0.7153 0.6962 0.8112 0.1456 0.0001
2 amd_semi 0.2970 0.2948 0.3219 0.0050 0.0007
3 amzn_disc 0.4059 0.3955 0.4538 0.1401 0.0004
4 cost_def 0.2857 0.2813 0.2854 0.0843 0.0002
5 gdx_gold 0.3689 0.3679 0.3923 -0.2578 0.0006
6 hyg_credit 0.4942 0.4868 0.5236 0.0798 0.0002
7 msft_tech 0.5283 0.5264 0.5473 0.1770 0.0002
8 nvda_semi 0.4613 0.4584 0.4859 0.1705 0.0005
9 qqq_spy 0.6491 0.6452 0.6592 0.1652 0.0001
10 tlt_ief 0.6078 0.5984 0.5837 0.2291 0.0002
11 tsla_disc 0.2408 0.2365 0.2485 0.1003 0.0011

When we compare the best model for each relationship, we don’t get one universal winner across this sample.

Ridge has the strongest median score, around 0.641, and wins four relationships: AAPL, COST, NVDA, and QQQ. This fits the theory. Ridge is especially useful when hedge variables are correlated and the target has a meaningful but noisy factor structure.

Kalman wins the most relationships, with five wins: AMD, GDX, HYG, MSFT, and TSLA. This doesn’t mean Kalman has the highest median score. It means it’s very useful in specific cases where smoother time variation beats rolling-window instability. Kalman also has the lowest median cost drag, which is economically important.

Rolling OLS wins AMZN and TLT. These are cases where more responsiveness helps enough to overcome higher turnover.

Static OLS only wins AGG/BND. That’s exactly the relationship where static OLS should win. AGG and BND are so structurally close that a dynamic model adds unnecessary movement.

The weakest relationship remains GDX/gold, even though Kalman wins it. The best score is only about 0.481, and the best max-drawdown difference is still negative. That’s a warning that the relationship is only partially hedgeable. We can reduce volatility, but we don’t fully control the worst path.

We can also check the best-minus-second score gap, because some of these wins may not be robust. If the best model barely beats the second-best model, the choice is more about implementation preference. If the gap is large, the model choice is more meaningful.

10.1 Model choice across hedge relationships

There’s no reason to expect one estimator to dominate every hedge problem. The underlying economics decide what kind of estimator is appropriate.

For replication relationships, like AGG/BND, the true beta is stable. Static OLS can win because dynamic models only add noise.

For factor-overlap relationships, like NVDA/SMH+QQQ, ridge can work well because the hedge assets explain common factors and can be correlated with each other. It can also shrink a single coefficient, as in QQQ/SPY.

For regime-sensitive relationships, like HYG credit or GDX gold miners, Kalman can help because exposures change across stress, recovery, inflation, and liquidity regimes.

For fast-changing relationships, rolling OLS can win because it responds faster than a conservative Kalman filter. That responsiveness costs more turnover, but sometimes the risk reduction is worth it.

Scoring each relationship separately lets us compare estimators applied to the same target and hedge basket. We can then connect the selected model to the relationship’s stability, overlap between hedge assets, and the amount of change in its exposures.

11) Residual spreads and stationarity testing

In the first part, we used hedge ratios for risk reduction. Now we’ll use price hedge ratios for residual trading.

The return hedge above reduces exposure to a set of assets. To trade a residual spread, we also want its deviations to return toward an equilibrium. A spread with low volatility can still drift for years, so we need to examine its behavior through time before using a deviation as a trading signal.

For residual trading we switch from returns to log prices. For a target price \(P^y_t\) and hedge price \(P^x_t\), the price relationship is:

\[ \log P^y_t = \alpha_t + \beta_t \log P^x_t + s_t \]

where:

  • \(\log P^y_t\) is the target log price,
  • \(\log P^x_t\) is the hedge log price,
  • \(\beta_t\) is the price hedge ratio or cointegrating slope,
  • \(\alpha_t\) is the intercept,
  • \(s_t\) is the residual spread.

Rearranging gives the spread directly:

\[ s_t = \log P^y_t - \alpha_t - \beta_t\log P^x_t \]

If \(s_t\) is stationary, deviations from its mean are temporary. A high \(s_t\) means the target is expensive relative to the hedge after adjusting for the estimated long-run relationship. A low \(s_t\) means the target is cheap relative to the hedge. This creates the logic for a mean-reversion trade.

If \(s_t\) has a unit root, the situation is different. A high spread can stay high, move higher, or reflect a permanent economic break. In that case, z-score trading becomes dangerous because we keep expecting a reversal that may never arrive, even after the spread moves a long way from its recent mean.

We’ll use stationarity and related tests to assess each price spread before using its deviations as trading signals. The tests and the trading rules give us two ways to study the same relative-price relationship.

11.1 ADF, Engle-Granger, and half-life

The Augmented Dickey-Fuller test checks whether the spread behaves like a stationary process or like a drifting process with a unit root. A simple version of the test regression is:

\[ \Delta s_t = a + \gamma s_{t-1} + \sum_{j=1}^{p}\phi_j\Delta s_{t-j} + u_t \]

Here:

  • \(\Delta s_t = s_t - s_{t-1}\) is the daily change in the spread,
  • \(s_{t-1}\) is yesterday’s spread level,
  • \(a\) is a constant drift term,
  • the lagged differences \(\Delta s_{t-j}\) control for autocorrelation in spread changes,
  • \(u_t\) is the remaining error term,
  • \(\gamma\) measures the pull of the previous spread level on the current change.

The hypotheses are:

\[ H_0: \gamma = 0 \quad \text{unit root, non-stationary spread} \]

\[ H_1: \gamma < 0 \quad \text{stationary, mean-reverting spread} \]

The sign of \(\gamma\) matters. If \(s_{t-1}\) is high relative to its equilibrium and \(\gamma < 0\), the expected change \(\Delta s_t\) is negative, pulling the spread back down. If \(s_{t-1}\) is below that equilibrium, the same negative \(\gamma\) implies a positive expected move. This is mean reversion in regression form.

Engle-Granger cointegration is related, but the starting question is slightly different. It asks whether two non-stationary price series share a stable long-run relationship. First we estimate:

\[ y_t = \alpha + \beta x_t + s_t \]

Then we test whether \(s_t\) is stationary. If \(y_t\) and \(x_t\) individually wander through time but their residual \(s_t\) mean-reverts, the pair is cointegrated. Financially, this means the two assets can drift, trend, and compound, but their relative valuation has an equilibrium pull.

Half-life translates the strength of mean reversion into trading language. We estimate:

\[ \Delta s_t = a + b s_{t-1} + e_t \]

and approximate:

\[ \text{half-life} = -\frac{\ln 2}{b}, \quad b < 0 \]

This expression is an approximation that works best when the estimated reversion coefficient is small in magnitude. If the half-life is 10 trading days, a spread shock is expected to decay by about half in roughly two weeks. If the half-life is 60 trading days, the same trade may require three months of patience. If the half-life is infinite or negative in the wrong way, the spread isn’t mean-reverting enough for this setup.

Here we require a half-life between 2 and 63 trading days. Very fast spreads are hard to trade after costs. Very slow spreads can trap capital and generate painful drawdowns before the expected reversion happens.

Show code
from statsmodels.tsa.stattools import adfuller, coint


def price_ols_beta(px, target, hedge):
    lp = np.log(px[[target, hedge]][px[[target, hedge]] > 0]).dropna()
    out = pd.DataFrame(np.nan, index=px.index, columns=["alpha", "beta"])
    a, b = ols_fit(lp[target].iloc[:n_train], lp[[hedge]].iloc[:n_train])
    out.loc[lp.index[n_train - 1]:, ["alpha", "beta"]] = [a, b[0]]
    return out


def roll_price_beta(px, target, hedge):
    lp = np.log(px[[target, hedge]][px[[target, hedge]] > 0]).dropna()
    out = pd.DataFrame(np.nan, index=px.index, columns=["alpha", "beta"])
    for i in range(max(win, n_train) - 1, len(lp)):
        sample = lp.iloc[i - win + 1:i + 1]
        a, b = ols_fit(sample[target], sample[[hedge]])
        out.loc[lp.index[i], ["alpha", "beta"]] = [a, b[0]]
    return out


def kf_price_beta(px, target, hedge):
    lp = np.log(px[[target, hedge]][px[[target, hedge]] > 0]).dropna()
    out = pd.DataFrame(np.nan, index=px.index, columns=["alpha", "beta"])
    a, b = ols_fit(lp[target].iloc[:n_train], lp[[hedge]].iloc[:n_train])
    state = np.array([a, b[0]], dtype=float)
    h0 = np.column_stack([np.ones(n_train), lp[hedge].iloc[:n_train].to_numpy(float)])
    resid = lp[target].iloc[:n_train].to_numpy(float) - h0 @ state
    obs_var = max(np.nanvar(resid, ddof=1), 1e-8)
    pcov = np.eye(2) * 0.10
    qcov = np.diag([1e-7, 1e-5])
    out.loc[lp.index[n_train - 1], ["alpha", "beta"]] = state
    for i in range(n_train, len(lp)):
        h = np.array([1.0, lp[hedge].iloc[i]])
        pcov = pcov + qcov
        s = float(h @ pcov @ h.T + obs_var)
        k = (pcov @ h.T) / s
        state = state + k * (lp[target].iloc[i] - h @ state)
        pcov = (np.eye(2) - np.outer(k, h)) @ pcov
        out.loc[lp.index[i], ["alpha", "beta"]] = state
    return out


def log_spread(px, target, hedge, beta):
    lp = np.log(px[[target, hedge]][px[[target, hedge]] > 0])
    b = beta.reindex(lp.index).ffill()
    return (lp[target] - b["alpha"] - b["beta"] * lp[hedge]).replace([np.inf, -np.inf], np.nan)


def eg_test(y, x):
    z = pd.concat([y, x], axis=1).dropna()
    return float(coint(z.iloc[:, 0], z.iloc[:, 1])[1]) if len(z) >= 30 else np.nan


def adf_test(s):
    x = pd.Series(s).dropna()
    try:
        return float(adfuller(x, autolag="AIC")[1]) if len(x) >= 30 else np.nan
    except (ValueError, np.linalg.LinAlgError):
        return np.nan


def half_life(s):
    x = pd.Series(s).dropna()
    z = pd.concat([x.diff(), x.shift(1)], axis=1).dropna()
    if len(z) < 30:
        return np.nan
    _, b = ols_fit(z.iloc[:, 0], z.iloc[:, [1]])
    return -np.log(2) / b[0] if b[0] < 0 else np.inf

11.2 Z-score trading rule

After a spread passes the statistical gate, we need a way to turn its deviations into entry and exit decisions. We use a rolling z-score:

\[ z_t = \frac{s_t - \bar{s}_{t,126}}{\sigma_{s,t,126}} \]

where:

  • \(s_t\) is today’s residual spread,
  • \(\bar{s}_{t,126}\) is the 126-day rolling mean of the spread,
  • \(\sigma_{s,t,126}\) is the 126-day rolling standard deviation,
  • \(z_t\) measures how many recent standard deviations the spread is away from its local mean.

The rule is simple:

Condition Position logic
\(z_t > 2\) target is rich relative to hedge, short the spread
\(z_t < -2\) target is cheap relative to hedge, long the spread
\(|z_t| < 0.5\) spread has mostly normalized, exit
\(|z_t| > 3.5\) move is too extreme, stop and cool down

A very large z-score can reflect a substantial move away from the recent relationship, so we close the position at the stop threshold. The five-trading-day cool-down period then keeps us from immediately re-entering after that large adverse move.

We lag the signal by one day before trading, so we don’t use same-day information incorrectly. If the spread position is \(q_t \in \{-1,0,+1\}\), the daily strategy return is:

\[ r^{strat}_t = q_{t-1}\left(r^y_t - \beta_{t-1}r^x_t\right) - \text{cost}_t \]

The term inside parentheses is the hedged residual return. The lagged \(q_{t-1}\) means the position was decided before today’s return was known. The cost term subtracts trading costs from position changes.

The resulting position trades mean reversion in the residual. Its payoff depends on the target’s movement relative to the hedge and on the estimated relationship returning toward its recent equilibrium.

Show code
z_win = 126
z_in = 2.0
z_out = 0.5
z_stop = 3.5
z_cool = 5

def z_signal(spread):
    z = (spread - spread.rolling(z_win).mean()) / spread.rolling(z_win).std()
    position, cooldown, positions = 0.0, 0, []
    for value in z:
        if np.isfinite(value):
            if abs(value) > z_stop:
                position = 0.0
                cooldown = z_cool
            elif cooldown > 0:
                position = 0.0
                cooldown -= 1
            elif position == 0 and value > z_in:
                position = -1.0
            elif position == 0 and value < -z_in:
                position = 1.0
            elif abs(value) < z_out:
                position = 0.0
        positions.append(position)
    return pd.DataFrame({"z": z, "signal": pd.Series(positions, index=z.index).shift(1).fillna(0.0)})


def spread_book(ret, target, hedge, beta, signal, cost_bps=cost_bps):
    panel = ret[[target, hedge]].dropna()
    b = beta["beta"].reindex(panel.index).ffill().shift(1)
    sig = signal.reindex(panel.index).fillna(0.0)
    data = pd.concat([panel, b.rename("beta"), sig.rename("signal")], axis=1).dropna()
    spread_ret = data[target] - data["beta"] * data[hedge]
    gross = data["signal"] * spread_ret
    weights = pd.DataFrame({target: data["signal"], hedge: -data["signal"] * data["beta"]})
    turnover = weights.diff().abs().sum(axis=1)
    if len(turnover):
        turnover.iloc[0] = weights.iloc[0].abs().sum()
    cost = turnover * cost_bps / 10000.0
    net = gross - cost
    return {"target_return": spread_ret, "gross_return": gross, "net_return": net,
            "turnover": turnover, "cost": cost,
            "beta": beta[["beta"]].rename(columns={"beta": hedge}).reindex(data.index).ffill(),
            "gross_values": (1 + gross).cumprod(), "net_values": (1 + net).cumprod(),
            "target_values": (1 + spread_ret).cumprod()}

11.3 Dynamic residuals and the trading gate

With dynamic hedge ratios, we make the spreads more stationary, but we have to interpret that result carefully. If \(\beta_t\) changes through time, the spread is no longer the residual of one fixed long-run relationship. It’s the residual after allowing the relationship itself to move.

That can be useful for trading because markets aren’t static. Currency regimes, duration relationships, and sector betas change. But it also means an ADF test on a dynamic residual isn’t the same as proving classical cointegration under a fixed beta.

For that reason, we use different gate conditions for static and dynamic sources. Static spreads are expected to pass a stricter long-run relation test. Dynamic spreads are allowed if the resulting residual is stationary, tradable, and not created by excessive beta turnover.

We also impose a limit on the average absolute change in the price hedge ratio:

\[ \overline{|\Delta \beta|} \le 0.25 \]

Without this filter, a hyperactive beta could force almost any spread to look stationary by chasing the target too closely. We want adaptive residuals, not overfit residuals.

Show code
pairs = [("agg_bnd", "agg", "bnd"), ("gdx_gld", "gdx", "gld"),
         ("tlt_ief", "tlt", "ief"), ("hyg_lqd", "hyg", "lqd"),
         ("xlk_qqq", "xlk", "qqq"), ("qqq_spy", "qqq", "spy")]
pairs = [p for p in pairs if p[1] in px.columns and p[2] in px.columns]
source_fns = {"static_log_ols": lambda t, h: price_ols_beta(px, t, h),
              "rolling_log_ols": lambda t, h: roll_price_beta(px, t, h),
              "kalman_log": lambda t, h: kf_price_beta(px, t, h)}

gate_rows, resid_bt, resid_z, resid_meta = [], {}, {}, {}
for pair_name, target, hedge in pairs:
    lp = np.log(px[[target, hedge]].dropna())
    eg_p = eg_test(lp[target], lp[hedge])
    for beta_source, fn in source_fns.items():
        params = fn(target, hedge)
        spread = log_spread(px, target, hedge, params)
        sig = z_signal(spread)
        key = f"{pair_name} | {beta_source}"
        res = book_result(spread_book(ret_b, target, hedge, params, sig["signal"]))
        sdrop = spread.dropna()
        split = max(len(sdrop) // 2, 30)
        first, second = sdrop.iloc[:split], sdrop.iloc[split:]
        trades = int((sig["signal"].ne(0) & sig["signal"].shift(1).fillna(0).eq(0)).sum())
        cost_drag = max(total_return(res.gross_values) - total_return(res.net_values), 0.0)
        beta_turnover = params["beta"].dropna().diff().abs().mean()
        gate_rows.append({"pair": pair_name, "beta_source": beta_source, "eg_p": eg_p,
                          "adf_p": adf_test(spread), "half_life": half_life(spread),
                          "spread_vol": spread.diff().std() * np.sqrt(ann), "trades": trades,
                          "cost_drag": cost_drag, "cost_drag_ann": res.cost.mean() * ann if len(res.cost) else 0.0,
                          "break_p": max(adf_test(first), adf_test(second)) if len(second) >= 30 else np.nan,
                          "beta_turnover": beta_turnover, "key": key})
        resid_bt[key], resid_z[key] = res, sig
        resid_meta[key] = {"pair": pair_name, "beta_source": beta_source}

tab_gate = pd.DataFrame(gate_rows)
static_ok = tab_gate["beta_source"].str.contains("static")
tab_gate["eligible"] = (
    ((~static_ok) | (tab_gate["eg_p"] <= 0.10))
    & (tab_gate["adf_p"] <= 0.10)
    & (tab_gate["half_life"].between(2, 63))
    & (tab_gate["spread_vol"] >= 0.005)
    & (tab_gate["trades"] >= 3)
    & (tab_gate["cost_drag_ann"] <= 0.10)
    & (tab_gate["break_p"].fillna(1.0) <= 0.20)
    & (tab_gate["beta_turnover"].fillna(0.0) <= 0.25)
)
display(tab_gate[["pair", "beta_source", "eg_p", "adf_p", "half_life", "spread_vol", "trades",
                  "cost_drag_ann", "beta_turnover", "eligible"]].round(4))
pair beta_source eg_p adf_p half_life spread_vol trades cost_drag_ann beta_turnover eligible
0 agg_bnd static_log_ols 0.2615 0.0457 4.3384 0.0159 44 0.0097 0.0000 False
1 agg_bnd rolling_log_ols 0.2615 0.0000 1.6903 0.0158 52 0.0115 0.0004 False
2 agg_bnd kalman_log 0.2615 0.0000 0.6732 0.0001 70 0.0152 0.0001 False
3 gdx_gld static_log_ols 0.1076 0.9985 inf 0.3423 22 0.0109 0.0000 False
4 gdx_gld rolling_log_ols 0.1076 0.0000 18.7606 0.2400 28 0.0086 0.0074 True
5 gdx_gld kalman_log 0.1076 0.0000 2.5041 0.2760 41 0.0194 0.0008 True
6 tlt_ief static_log_ols 1.0000 0.9979 inf 0.0625 26 0.0084 0.0000 False
7 tlt_ief rolling_log_ols 1.0000 0.0028 37.0943 0.0667 26 0.0083 0.0057 True
8 tlt_ief kalman_log 1.0000 0.0000 1.2118 0.0357 66 0.0217 0.0004 False
9 hyg_lqd static_log_ols 0.9235 0.9566 562.7608 0.0715 28 0.0054 0.0000 False
10 hyg_lqd rolling_log_ols 0.9235 0.0028 42.5265 0.0707 28 0.0052 0.0055 True
11 hyg_lqd kalman_log 0.9235 0.0000 1.2585 0.0539 57 0.0111 0.0003 False
12 xlk_qqq static_log_ols 0.2709 0.2784 170.8249 0.0603 28 0.0068 0.0000 False
13 xlk_qqq rolling_log_ols 0.2709 0.0000 30.4045 0.0598 30 0.0069 0.0014 True
14 xlk_qqq kalman_log 0.2709 0.0000 1.1086 0.0307 74 0.0174 0.0003 False
15 qqq_spy static_log_ols 0.5001 0.3215 368.7310 0.0848 23 0.0052 0.0000 False
16 qqq_spy rolling_log_ols 0.5001 0.0244 80.1845 0.0865 22 0.0058 0.0032 False
17 qqq_spy kalman_log 0.5001 0.0000 1.2170 0.0440 66 0.0154 0.0004 False

We use a strict residual gate because a visually correlated pair doesn’t automatically give us a useful trading signal.

All static log-OLS spreads fail at least one of our gate conditions. Some have weak stationarity evidence, while others fail the Engle-Granger or half-life requirement. For example, GDX/GLD static has ADF p-value around 0.9985 and infinite half-life, so it clearly fails. The spread can drift, and a z-score strategy would be dangerous.

The dynamic log-OLS spreads often pass because the changing beta absorbs slow structural drift. Eligible rolling spreads include:

  • GDX/GLD, with ADF p-value near zero and half-life around 18.8 days,
  • TLT/IEF, with ADF p-value around 0.0028 and half-life around 37.1 days,
  • HYG/LQD, with ADF p-value around 0.0028 and half-life around 42.5 days,
  • XLK/QQQ, with ADF p-value near zero and half-life around 30.4 days.

The Kalman log spreads often have very small half-lives, sometimes near 1 day. That sounds attractive at first, but it can be a warning. A very adaptive beta can absorb too much of the spread movement, leaving a residual that looks statistically stationary but has too little economically tradable structure. We therefore check half-life, spread volatility, trade count, cost, and beta turnover along with the ADF p-values when choosing a residual spread to trade.

Show code
tab_resid = residual_trade_table(resid_bt, resid_z, resid_meta, ann=ann)
pick_gate = tab_gate[tab_gate["eligible"] & ~tab_gate["beta_source"].eq("kalman_log")]
rolling_keys = pick_gate.loc[pick_gate["beta_source"].eq("rolling_log_ols"), "key"].tolist()
keep_keys = rolling_keys or pick_gate["key"].tolist()

display(tab_resid[tab_resid["key"].isin(keep_keys)].drop(columns="key").round(4))

fig, axes = plt.subplots(math.ceil(len(keep_keys[:6]) / 2), 2,
                         figsize=(12, 2.8 * math.ceil(len(keep_keys[:6]) / 2)), squeeze=False)
for ax, key in zip(axes.ravel(), keep_keys[:6]):
    resid_z[key]["z"].dropna().plot(ax=ax, lw=1.0)
    ax.axhline(z_in, ls="--", lw=0.8, c=colors[1])
    ax.axhline(-z_in, ls="--", lw=0.8, c=colors[3])
    ax.set_title(key)
    ax.grid(True, alpha=0.25)
for ax in axes.ravel()[len(keep_keys[:6]):]:
    ax.axis("off")
plt.tight_layout()
plt.show()

fig, ax = plt.subplots(figsize=(10, 4))
for key in keep_keys[:6]:
    nav = resid_bt[key].net_values
    ax.plot(nav.index, nav / nav.iloc[0], label=key)
ax.set_title("residual strategy nav")
ax.legend(fontsize=8)
ax.grid(True, alpha=0.25)
plt.tight_layout()
plt.show()
pair beta_source trades avg_hold net_return ann_vol sharpe maxdd cost_drag cost_drag_ann
4 gdx_gld rolling_log_ols 28 25.1429 0.8787 0.1181 0.6535 -0.1395 0.1518 0.0086
7 tlt_ief rolling_log_ols 26 32.1538 -0.0721 0.0469 -0.1545 -0.1949 0.0718 0.0083
10 hyg_lqd rolling_log_ols 28 27.9643 0.0917 0.0408 0.2597 -0.0610 0.0520 0.0052
13 xlk_qqq rolling_log_ols 30 24.6000 0.0582 0.0336 0.2045 -0.0893 0.0677 0.0069

12) Residual trading results

After the gates, we focus on selected rolling log-OLS residual strategies. We compare trades, average holding period, net return, annualized volatility, Sharpe, max drawdown, and cost drag for these selected spreads.

The strongest result is GDX/GLD rolling log-OLS. It has 28 trades, average holding period around 25 days, net return around 87.9%, annualized volatility around 11.8%, and Sharpe around 0.65. Gold miners versus gold can produce wide deviations because miners behave partly like equities and partly like leveraged gold exposure. The two sides of the spread therefore respond differently when equity risk or mining margins change. When the spread is estimated dynamically, some deviations appear tradable.

HYG/LQD is modest but positive, with net return around 9.2% and Sharpe around 0.26. That fits the credit-spread intuition. High yield and investment-grade credit are related, but HYG has equity and default-risk components that can overshoot.

XLK/QQQ is also positive but small. That makes sense because XLK and QQQ overlap heavily. The spread is cleaner, but the opportunity set is narrower.

TLT/IEF passes the statistical gate but loses money, with net return around -7.2% and Sharpe around -0.15. Here we can compare the stationary-looking spread with the outcome of applying the entry, exit, and cost rules to that same relationship. The residual might revert, but not fast enough after costs and signal timing, or the rate-regime shifts may create losses during the holding period.

Looking at the z-score paths, we can see why residual trading needs discipline. The spread often spends long periods near zero, then moves sharply through entry levels. We enter when the deviation is large enough and exit when the residual normalizes or the stop rule is triggered.

We can also compare the residual NAV paths after trading costs. GDX/GLD dominates the selected residual strategies. It has step-like gains, which means the strategy earns most of its return during specific episodes rather than smoothly every month. HYG/LQD and XLK/QQQ grow slowly. TLT/IEF drifts below 1 and stays weak.

The return paths depend on when deviations appear and how the entry, exit, and stop rules act on them. Our stationarity tests help select candidates, and the net P&L paths then show how the selected spreads perform after trading costs across the sample. When the position is flat, the spread contributes no gross return to the strategy.

Show code
display(tab_best.round(4))
display(tab_resid[tab_resid["key"].isin(keep_keys)].drop(columns="key").round(4))

fig, axes = plt.subplots(1, 2, figsize=(15, 4.8))
best_plot = tab_best.sort_values("score").copy()
model_order = list(best_plot["best_model"].dropna().unique())
model_colors = {m: colors[i % len(colors)] for i, m in enumerate(model_order)}
axes[0].barh(best_plot["relationship"], best_plot["score"],
             color=[model_colors[m] for m in best_plot["best_model"]], alpha=0.88)
axes[0].set_title("main hedge score")
axes[0].set_xlabel("score")
axes[0].grid(True, axis="x", alpha=0.25)
for key in keep_keys[:6]:
    nav = resid_bt[key].net_values
    axes[1].plot(nav.index, nav / nav.iloc[0], label=key)
axes[1].set_title("selected residual nav")
axes[1].legend(fontsize=7)
axes[1].grid(True, alpha=0.25)
plt.tight_layout()
plt.show()
relationship best_model score vol_red es_red maxdd_diff cost_drag_ann
0 aapl_tech ridge 0.7829 0.4397 0.4676 0.0575 0.0006
1 agg_bnd static 0.8328 0.7153 0.8112 0.1456 0.0001
2 amd_semi kalman 0.7801 0.2970 0.3185 -0.0112 0.0002
3 amzn_disc rolling 0.7000 0.4059 0.4538 0.1401 0.0009
4 cost_def ridge 0.7187 0.2857 0.2854 0.0843 0.0004
5 gdx_gold kalman 0.4807 0.3685 0.3884 -0.2578 0.0006
6 hyg_credit kalman 0.7479 0.4866 0.5234 0.0531 0.0001
7 msft_tech kalman 0.8533 0.5283 0.5442 0.1731 0.0002
8 nvda_semi ridge 0.7379 0.4613 0.4859 0.1700 0.0008
9 qqq_spy ridge 0.7087 0.6491 0.6586 0.1652 0.0001
10 tlt_ief rolling 0.6924 0.6078 0.5837 0.2272 0.0003
11 tsla_disc kalman 0.6207 0.2350 0.2454 0.0927 0.0003
pair beta_source trades avg_hold net_return ann_vol sharpe maxdd cost_drag cost_drag_ann
4 gdx_gld rolling_log_ols 28 25.1429 0.8787 0.1181 0.6535 -0.1395 0.1518 0.0086
7 tlt_ief rolling_log_ols 26 32.1538 -0.0721 0.0469 -0.1545 -0.1949 0.0718 0.0083
10 hyg_lqd rolling_log_ols 28 27.9643 0.0917 0.0408 0.2597 -0.0610 0.0520 0.0052
13 xlk_qqq rolling_log_ols 30 24.6000 0.0582 0.0336 0.2045 -0.0893 0.0677 0.0069

13) Final applied output for the main universe

We can now bring together the two questions we studied for the main universe:

  1. the best hedge model for each target relationship,
  2. the selected residual strategies that passed the trading gate.

The highest-scoring hedge models for the sample give us this mix:

  • Kalman is selected for AMD, GDX, HYG, MSFT, and TSLA.
  • Ridge is selected for AAPL, COST, NVDA, and QQQ.
  • Rolling OLS is selected for AMZN and TLT.
  • Static OLS is selected only for AGG/BND.

The selected models also fit the differences between the relationships: the close AGG/BND pair favors a fixed coefficient, several correlated stock hedge baskets favor ridge, and a number of changing relationships favor Kalman. AMZN and TLT select the rolling estimate in this comparison.

The best scores are highest for MSFT, AGG/BND, AAPL, AMD, and HYG. GDX has the lowest best score because miners are only partially hedgeable. TSLA also has a lower score because it carries large company-specific risk that sector and growth hedges can’t fully remove.

For the selected residual NAV paths, the trading results are also uneven: GDX/GLD is the standout residual trade, while the others are modest or weak. So the main project has two separate conclusions. Dynamic hedge ratios are broadly useful for risk reduction, but residual trading is selective. We shouldn’t trade a residual just because we can estimate a beta.

14) International ETF transfer with the library

Now we repeat the same workflow on international ETFs using the quantfinlab hedging tools. These ETFs bring in currency and country exposures alongside the market and sector risks we considered in the main examples. We can compare how those different exposures behave across country and currency ETFs.

For reproduction, check the international_hedging_etfs folder in the data directory. We read the generated international ETF panel locally; the folder explains how to create it. We also use the supporting ETF sources from the core cross-asset and sector ETF data folders for the hedge assets.

International equity ETFs combine several overlapping risk layers:

  • local equity market beta, such as Japan, Europe, China, Brazil, India, Taiwan, or Korea,
  • currency translation, because unhedged country ETFs are priced in USD even when the local market moves in another currency,
  • global equity beta, because most country ETFs still sell off when global risk appetite collapses,
  • commodity sensitivity, especially for Canada, Australia, and Brazil,
  • regional supply-chain exposure, especially Taiwan and Korea through semiconductors,
  • dollar liquidity pressure, because a strong US dollar often tightens financial conditions for emerging markets.

We choose hedge baskets to represent these layers in each country’s returns. The fitted coefficients then tell us how the target moves with the available equity, currency, and commodity proxies, while the remaining return captures exposures outside the chosen basket.

14.0 International hedging as economic decomposition

With these country ETFs, we can test whether hedge ratios separate their returns into recognizable macro components and the residual left over.

For US single stocks, the hedge problem is usually sector and market beta. For international ETFs, the target return can be decomposed more like:

\[ r^{country}_t \approx \beta_1 r^{local\ equity}_t + \beta_2 r^{FX}_t + \beta_3 r^{global\ risk}_t + \beta_4 r^{commodity}_t + \varepsilon_t \]

This equation isn’t one universal model for every country. It’s a template for thinking. The relevant components differ by market:

  • Japan needs yen exposure and a currency-hedged Japan ETF.
  • Europe needs euro exposure and a currency-hedged eurozone ETF.
  • Canada and Australia need commodity and local currency exposure.
  • Taiwan and Korea need semiconductor and global technology exposure.
  • Brazil and India need EM beta and dollar-pressure controls.
  • China needs China-specific proxies rather than only broad EM.

A strong result here means the hedge model is capturing real economic structure. If EFA can be hedged by HEFA and UUP, we’re separating developed international equity from currency translation. If Canada can be hedged by SPY, XLE, DBC, and FXC, we’re decomposing Canada into US/global equity linkage, energy/commodity exposure, and Canadian-dollar movement.

Applying the same estimators across these country ETFs lets us compare how much of each target’s movement is captured by its chosen macro proxies. The remaining residual can then be interpreted alongside the country and sector exposures in that basket.

14.1 International relationships and economic design

For each international relationship, we choose a hedge basket around the country’s equity, currency, commodity, or regional exposures, using the assets listed below:

Target relationship Hedge assets Economic interpretation
EFA/HEFA HEFA, UUP developed ex-US equity with currency-hedged EAFE and dollar exposure
EWJ/DXJ DXJ, FXY, SPY Japan equity after yen exposure and global equity beta
EZU/HEZU HEZU, FXE, SPY eurozone equity after euro movement and global risk beta
EWC macro SPY, XLE, DBC, FXC Canada through US linkage, energy/commodity exposure, and CAD
EWA macro EFA, DBC, FXA Australia through developed ex-US beta, commodities, and AUD
EWH macro FXI, MCHI, VNQ, UUP Hong Kong through China exposure, property sensitivity, and dollar liquidity
EWT macro EEM, SMH, QQQ, UUP Taiwan through EM, semiconductors, global tech, and dollar pressure
EWY macro EEM, SMH, EWT, UUP Korea through EM, semiconductors, Taiwan linkage, and dollar pressure
INDA macro EEM, QQQ, UUP India as EM growth with global tech/style and dollar sensitivity
EWZ macro EEM, DBC, UUP Brazil as EM plus commodities and dollar exposure
EWW macro SPY, EEM, UUP Mexico through US economic linkage, EM beta, and dollar exposure
FXI macro MCHI, KWEB, ASHR, UUP China large caps through broad China, internet, A-shares, and dollar exposure

We can use \(R^2\) to evaluate how much of a target’s return variation the proxy explains, then interpret the remaining residual using the makeup of the hedge basket. If a country ETF remains hard to hedge with these macro proxies, the residual may contain local political risk, earnings composition, sector weights, index construction, or market-specific investor flows.

Show code
import pandas as pd
import matplotlib.pyplot as plt
from cycler import cycler
from IPython.display import display

from quantfinlab.backtest.hedging import run_many_hedge_backtests
from quantfinlab.dataio import load_yfinance_panel
from quantfinlab.hedging import (
    band_beta, best_table, coverage_table, diag_table, filter_rels,
    kf_beta, kf_price_beta, model_table, ols_beta, price_ols_beta,
    quality_table, rebalance_beta, rel, rel_table, rel_tickers,
    residual_backtest_grid, residual_trade_table, ridge_beta, robust_table,
    roll_beta, roll_price_beta, score_table)
from quantfinlab.plotting.hedging import (
    plot_best_score_bar, plot_model_counts, plot_quality_bar,
    plot_resid_gate_counts, plot_resid_nav, plot_risk_reduction_scatter,
    plot_score_cost_scatter, plot_score_gap, plot_turnover_cost_scatter)
from quantfinlab.portfolio.universe import prices_to_returns

start = "2015-01-01"
end = "2026-01-01"
ann = 252
n_train = 504
win = 252
alpha = 10.0
freq = "w-fri"
band = 0.05
cost_bps = 5.0
z_win = 126
z_in = 2.0
z_out = 0.5
z_stop = 3.5
z_cool = 5

colors = ["#069AF3", "#FE420F", "#00008B", "#008080", "#CC79A7",
           "#9614fa", "#DC143C", "#7BC8F6", "#0072B2", "#04D8B2", "#800080", "#FF8072"]
plt.rcParams["axes.prop_cycle"] = cycler(color=colors)
plt.rcParams.update({
    "figure.figsize": (6, 3),
    "figure.dpi": 200,
    "savefig.dpi": 300,
    "axes.grid": True,
    "grid.alpha": 0.20,
    "axes.spines.top": False,
    "axes.spines.right": False,
    "axes.titlesize": 12,
    "axes.labelsize": 12,
    "xtick.labelsize": 9,
    "ytick.labelsize": 9,
    "legend.fontsize": 7})

etf_px_sec = load_yfinance_panel(["../data/international_hedging_etfs.csv", "../data/core_cross_asset_etfs.csv",
                                 "../data/sector_etfs.csv"], fields=("close",),
                                start=start, end=end, lowercase=True)["close"]
stock_px_sec = load_yfinance_panel("../data/nasdaq_close_volume.parquet", fields=("close",),
                                  start=start, end=end, lowercase=True)["close"]

px_sec_all = pd.concat([stock_px_sec, etf_px_sec], axis=1)
px_sec_all = px_sec_all.loc[:, ~px_sec_all.columns.duplicated(keep="last")]
px_sec_all = px_sec_all.loc[(px_sec_all.index >= start) & (px_sec_all.index <= end)].sort_index()
ret_m_all = prices_to_returns(px_sec_all, kind="log")
ret_b_all = prices_to_returns(px_sec_all, kind="simple")

rel_sec = [
    rel("efa_hefa", "efa", ["hefa", "uup"]),
    rel("ewj_dxj", "ewj", ["dxj", "fxy", "spy"]),
    rel("ezu_hezu", "ezu", ["hezu", "fxe", "spy"]),
    rel("ewc_macro", "ewc", ["spy", "xle", "dbc", "fxc"]),
    rel("ewa_macro", "ewa", ["efa", "dbc", "fxa"]),
    rel("ewh_macro", "ewh", ["fxi", "mchi", "vnq", "uup"]),
    rel("ewt_macro", "ewt", ["eem", "smh", "qqq", "uup"]),
    rel("ewy_macro", "ewy", ["eem", "smh", "ewt", "uup"]),
    rel("inda_macro", "inda", ["eem", "qqq", "uup"]),
    rel("ewz_macro", "ewz", ["eem", "dbc", "uup"]),
    rel("eww_macro", "eww", ["spy", "eem", "uup"]),
    rel("fxi_macro", "fxi", ["mchi", "kweb", "ashr", "uup"])]

sec_view = rel_table(rel_sec, px_sec_all.columns)
rel_sec, missing_sec = filter_rels(rel_sec, px_sec_all.columns)
tickers_sec = rel_tickers(rel_sec)
print(f"available secondary tickers: {len(tickers_sec)}")
print("missing secondary tickers:", missing_sec)
display(sec_view)

px_sec = px_sec_all[tickers_sec]
ret_m_sec = ret_m_all[tickers_sec]
ret_b_sec = ret_b_all[tickers_sec]
display(coverage_table(px_sec, rel_sec).round(4))

betas, books_sec, blog_sec = {}, {}, {}
for r in rel_sec:
    betas[(r.name, "static")] = ols_beta(ret_m_sec, r, n_train=n_train)
    betas[(r.name, "rolling")] = roll_beta(ret_m_sec, r, win=win, n_train=n_train)
    betas[(r.name, "ridge")] = ridge_beta(ret_m_sec, r, win=win, alpha=alpha, n_train=n_train)
    betas[(r.name, "kalman")] = kf_beta(ret_m_sec, r, n_train=n_train)
    books_sec[f"{r.name} | target"] = {"target": r.target, "hedges": r.hedges, "beta": None}
    for m in ["static", "rolling", "ridge", "kalman"]:
        beta_rebalanced = rebalance_beta(betas[(r.name, m)], ret_b_sec.index, freq=freq)
        beta_traded = beta_rebalanced.ffill() if m == "static" else band_beta(beta_rebalanced, band=band)
        books_sec[f"{r.name} | {m}"] = {"target": r.target, "hedges": r.hedges, "beta": beta_traded}
        blog_sec[f"{r.name} | {m}"] = {"rel": r.name, "model": m,
                                      "desired": betas[(r.name, m)], "traded": beta_traded}

bt_sec = run_many_hedge_backtests(books_sec, returns=ret_b_sec[tickers_sec], cost_bps=cost_bps)
tab_diag_sec = diag_table(ret_m_sec, rel_sec, beta_log=blog_sec, ann=ann)
tab_model_sec = model_table(bt_sec, rel_sec, ret_m_sec, ann=ann)
tab_score_sec = score_table(tab_model_sec)
tab_best_sec = best_table(tab_score_sec)
tab_robust_sec = robust_table(tab_score_sec)
tab_quality_sec = quality_table(tab_model_sec)

display(tab_diag_sec.round(4))
display(tab_best_sec.round(4))
display(tab_robust_sec.round(4))
display(tab_quality_sec.round(4))

pairs_sec = [("efa_hefa", "efa", "hefa"), ("ewj_dxj", "ewj", "dxj"), ("ezu_hezu", "ezu", "hezu"),
             ("fxi_mchi", "fxi", "mchi"), ("ewt_ewy", "ewt", "ewy")]
pairs_sec = [p for p in pairs_sec if p[1] in px_sec_all.columns and p[2] in px_sec_all.columns]
source_fns_sec = {
    "static_log_ols": lambda t, h: price_ols_beta(px_sec_all, t, h, n_train=n_train),
    "rolling_log_ols": lambda t, h: roll_price_beta(px_sec_all, t, h, win=win, n_train=n_train),
    "kalman_log": lambda t, h: kf_price_beta(px_sec_all, t, h, n_train=n_train)}

tab_gate_sec, resid_sec, z_sec, meta_sec = residual_backtest_grid(
    px_sec_all, ret_b_all, pairs_sec, source_fns_sec, ann=ann, cost_bps=cost_bps,
    z_win=z_win, z_in=z_in, z_out=z_out, z_stop=z_stop, z_cool=z_cool)

gate_cols = ["pair", "beta_source", "eg_p", "adf_p", "half_life", "spread_vol",
             "trades", "cost_drag_ann", "beta_turnover", "eligible"]
display(tab_gate_sec[gate_cols].round(4))

tab_resid_sec = residual_trade_table(resid_sec, z_sec, meta_sec, ann=ann)
pick_sec = tab_gate_sec[tab_gate_sec["eligible"] & ~tab_gate_sec["beta_source"].eq("kalman_log")]
rolling_sec = pick_sec.loc[pick_sec["beta_source"].eq("rolling_log_ols"), "key"].tolist()
keep_sec = rolling_sec or pick_sec["key"].tolist()
show_resid_sec = tab_resid_sec[tab_resid_sec["key"].isin(keep_sec)].copy()
display(show_resid_sec.drop(columns="key").round(4))

fig, axes = plt.subplots(3, 3, figsize=(17.5, 12.5))
axes = axes.ravel()
plot_best_score_bar(tab_best_sec, ax=axes[0], title="secondary best hedge score", colors=colors)
plot_risk_reduction_scatter(tab_score_sec, ax=axes[1], title="risk reduction by model", colors=colors)
plot_score_cost_scatter(tab_score_sec, ax=axes[2], title="score vs annual cost", colors=colors)
plot_turnover_cost_scatter(tab_score_sec, ax=axes[3], title="turnover and cost", colors=colors)
plot_model_counts(tab_best_sec, ax=axes[4], title="best model count", colors=colors)
plot_score_gap(tab_score_sec, ax=axes[5], title="score gap", colors=colors)
plot_quality_bar(tab_quality_sec, ax=axes[6], title="best vol reduction", colors=colors)
plot_resid_gate_counts(tab_gate_sec, ax=axes[7], title="eligible residual gates", colors=colors)
plot_resid_nav(resid_sec, keep_sec[:5], ax=axes[8], title="selected residual nav")
plt.tight_layout()
plt.show()
available secondary tickers: 30
missing secondary tickers: {}
relationship target hedges residual_pair included
0 efa_hefa efa hefa, uup True
1 ewj_dxj ewj dxj, fxy, spy True
2 ezu_hezu ezu hezu, fxe, spy True
3 ewc_macro ewc spy, xle, dbc, fxc True
4 ewa_macro ewa efa, dbc, fxa True
5 ewh_macro ewh fxi, mchi, vnq, uup True
6 ewt_macro ewt eem, smh, qqq, uup True
7 ewy_macro ewy eem, smh, ewt, uup True
8 inda_macro inda eem, qqq, uup True
9 ewz_macro ewz eem, dbc, uup True
10 eww_macro eww spy, eem, uup True
11 fxi_macro fxi mchi, kweb, ashr, uup True
relationship target hedges start end obs missing_pct included
0 efa_hefa efa hefa, uup 2015-01-02 2025-12-31 2766 0.0 True
1 ewj_dxj ewj dxj, fxy, spy 2015-01-02 2025-12-31 2766 0.0 True
2 ezu_hezu ezu hezu, fxe, spy 2015-01-02 2025-12-31 2766 0.0 True
3 ewc_macro ewc spy, xle, dbc, fxc 2015-01-02 2025-12-31 2766 0.0 True
4 ewa_macro ewa efa, dbc, fxa 2015-01-02 2025-12-31 2766 0.0 True
5 ewh_macro ewh fxi, mchi, vnq, uup 2015-01-02 2025-12-31 2766 0.0 True
6 ewt_macro ewt eem, smh, qqq, uup 2015-01-02 2025-12-31 2766 0.0 True
7 ewy_macro ewy eem, smh, ewt, uup 2015-01-02 2025-12-31 2766 0.0 True
8 inda_macro inda eem, qqq, uup 2015-01-02 2025-12-31 2766 0.0 True
9 ewz_macro ewz eem, dbc, uup 2015-01-02 2025-12-31 2766 0.0 True
10 eww_macro eww spy, eem, uup 2015-01-02 2025-12-31 2766 0.0 True
11 fxi_macro fxi mchi, kweb, ashr, uup 2015-01-02 2025-12-31 2766 0.0 True
relationship target_vol hedge_vol corr r2 beta_iqr obs
0 efa_hefa 0.1730 0.0920 0.6964 0.4849 0.0588 2765
1 ewj_dxj 0.1743 0.1119 0.9253 0.8562 0.0380 2765
2 ezu_hezu 0.2056 0.1212 0.9524 0.9071 0.0152 2765
3 ewc_macro 0.1918 0.1483 0.8479 0.7190 0.0699 2765
4 ewa_macro 0.2308 0.1200 0.8128 0.6606 0.1231 2765
5 ewh_macro 0.1983 0.1618 0.8140 0.6625 0.1575 2765
6 ewt_macro 0.2113 0.1690 0.8065 0.6504 0.0919 2765
7 ewy_macro 0.2468 0.1647 0.8023 0.6437 0.1115 2765
8 inda_macro 0.2151 0.1301 0.6796 0.4618 0.1448 2765
9 ewz_macro 0.3537 0.1037 0.6446 0.4155 0.2010 2765
10 eww_macro 0.2506 0.1181 0.6727 0.4525 0.1912 2765
11 fxi_macro 0.2793 0.2155 0.9372 0.8783 0.1058 2765
relationship best_model score vol_red es_red maxdd_diff cost_drag_ann
0 efa_hefa static 0.8057 0.8543 0.8717 0.2745 0.0001
1 ewa_macro kalman 0.5651 0.5561 0.5659 0.2174 0.0002
2 ewc_macro ridge 0.7109 0.5648 0.5636 0.2279 0.0004
3 ewh_macro ridge 0.6132 0.4344 0.4368 -0.0001 0.0004
4 ewj_dxj kalman 0.7336 0.7899 0.7939 0.0412 0.0001
5 ewt_macro rolling 0.7000 0.5082 0.5353 0.2079 0.0006
6 eww_macro kalman 0.6352 0.2886 0.3101 0.0280 0.0003
7 ewy_macro kalman 0.7313 0.4957 0.5081 0.0647 0.0002
8 ewz_macro ridge 0.6035 0.2770 0.2739 0.0255 0.0012
9 ezu_hezu kalman 0.8573 0.9132 0.9137 0.2433 0.0001
10 fxi_macro kalman 0.7509 0.7983 0.8023 0.3410 0.0001
11 inda_macro rolling 0.5828 0.3292 0.3179 0.1680 0.0008
model median_score median_vol_red median_es_red median_cost_drag_ann win_count failure_count
0 rolling 0.5840 0.5334 0.5465 0.0005 2 0
1 kalman 0.5780 0.5269 0.5397 0.0001 6 0
2 ridge 0.5253 0.5337 0.5486 0.0004 3 2
3 static 0.2530 0.5048 0.5325 0.0001 1 0
relationship best_vol_red median_vol_red best_es_red median_es_red best_maxdd_diff median_cost_drag_ann median_turnover_ann model_count
0 ezu_hezu 0.9132 0.9092 0.9137 0.9099 0.2592 0.0001 0.2628 4
1 efa_hefa 0.8552 0.8541 0.8722 0.8712 0.2797 0.0001 0.2811 4
2 fxi_macro 0.7983 0.7893 0.8023 0.7917 0.3446 0.0002 0.4449 4
3 ewj_dxj 0.7899 0.7857 0.7985 0.7905 0.0425 0.0002 0.3148 4
4 ewc_macro 0.5648 0.5559 0.5636 0.5583 0.2279 0.0002 0.4939 4
5 ewa_macro 0.5621 0.5581 0.5676 0.5660 0.2860 0.0003 0.6328 4
6 ewt_macro 0.5082 0.5039 0.5353 0.5283 0.2079 0.0003 0.6044 4
7 ewy_macro 0.4957 0.4927 0.5081 0.5041 0.1922 0.0004 0.8608 4
8 ewh_macro 0.4401 0.4224 0.4368 0.4287 0.1059 0.0003 0.6525 4
9 inda_macro 0.3294 0.3167 0.3193 0.3071 0.1680 0.0004 0.7361 4
10 eww_macro 0.2886 0.2873 0.3137 0.3102 0.0794 0.0007 1.3534 4
11 ewz_macro 0.2770 0.2657 0.2739 0.2564 0.0389 0.0007 1.3913 4
pair beta_source eg_p adf_p half_life spread_vol trades cost_drag_ann beta_turnover eligible
0 efa_hefa static_log_ols 0.2285 0.6914 466.7303 0.0685 20 0.0042 0.0000 False
1 efa_hefa rolling_log_ols 0.2285 0.0028 54.4932 0.0759 26 0.0062 0.0046 True
2 efa_hefa kalman_log 0.2285 0.0000 3.8359 0.0597 46 0.0096 0.0003 True
3 ewj_dxj static_log_ols 0.3112 0.1142 138.5739 0.1215 22 0.0032 0.0000 False
4 ewj_dxj rolling_log_ols 0.3112 0.0025 44.3013 0.1282 24 0.0049 0.0046 True
5 ewj_dxj kalman_log 0.3112 0.0000 4.7397 0.0996 44 0.0069 0.0003 True
6 ezu_hezu static_log_ols 0.3742 0.4043 218.5846 0.0825 21 0.0043 0.0000 False
7 ezu_hezu rolling_log_ols 0.3742 0.0022 52.5013 0.0862 24 0.0057 0.0043 True
8 ezu_hezu kalman_log 0.3742 0.0000 2.8434 0.0666 54 0.0111 0.0005 True
9 fxi_mchi static_log_ols 0.5376 0.1176 242.2865 0.0661 19 0.0044 0.0000 False
10 fxi_mchi rolling_log_ols 0.5376 0.0001 30.2740 0.0768 30 0.0061 0.0016 True
11 fxi_mchi kalman_log 0.5376 0.0000 2.8822 0.0509 56 0.0126 0.0003 True
12 ewt_ewy static_log_ols 0.7349 0.6984 678.8842 0.1740 22 0.0051 0.0000 False
13 ewt_ewy rolling_log_ols 0.7349 0.0010 46.5848 0.1612 25 0.0048 0.0052 True
14 ewt_ewy kalman_log 0.7349 0.0000 2.5042 0.1478 67 0.0162 0.0008 True
pair beta_source trades avg_hold net_return ann_vol sharpe maxdd cost_drag cost_drag_ann
1 efa_hefa rolling_log_ols 26 30.6923 -0.0155 0.0441 -0.0174 -0.1160 0.0561 0.0062
4 ewj_dxj rolling_log_ols 24 43.0833 -0.0257 0.0833 0.0065 -0.2879 0.0438 0.0049
7 ezu_hezu rolling_log_ols 24 32.0833 0.0647 0.0489 0.1671 -0.1329 0.0557 0.0057
10 fxi_mchi rolling_log_ols 30 22.5000 0.1537 0.0469 0.3631 -0.1054 0.0654 0.0061
13 ewt_ewy rolling_log_ols 25 31.2800 -0.3628 0.1147 -0.3805 -0.4856 0.0280 0.0048

All secondary tickers are available. The coverage table again shows 2766 observations from 2015 to the end of 2025 with no missing values. This makes the transfer test clean: the same hedge methodology is applied to a new economic universe without fighting data gaps.

Before fitting the full hedge models, we compare each target with the equal-weight average return of its hedge assets:

  • EZU/HEZU has correlation around 0.952 and \(R^2\) around 0.907. The equal-weight proxy made from HEZU, FXE, and SPY is closely related to the unhedged eurozone ETF. This is exactly what we would expect from a currency-hedged/unhedged structure.
  • EWJ/DXJ has correlation around 0.925 and \(R^2\) around 0.856. The equal-weight proxy is strongly related to Japan, and DXJ and FXY provide equity and yen exposure.
  • FXI macro has correlation around 0.937 and \(R^2\) around 0.878, because MCHI, KWEB, and ASHR are very close China-related proxies.
  • EWZ macro has much lower \(R^2\), around 0.416, and very high target volatility around 35.4%. Brazil is hard to hedge because it combines EM beta, commodity sensitivity, local political risk, currency shocks, and global dollar pressure.
  • EWW and INDA also have lower explanatory power. Mexico and India can’t be fully replicated by broad EM, US beta, and dollar exposure.

So before any backtest, the diagnostics already tell us which country ETFs are structurally hedgeable and which ones have more local residual risk.

14.2 Currency-hedged ETFs as natural controls

For some of the cleanest international relationships, we use currency-hedged ETFs as controls. HEFA, HEZU, and DXJ aren’t generic hedge assets. They are designed to hold similar local equity exposure while reducing foreign-exchange exposure.

A simplified unhedged USD return can be written as:

\[ r^{USD}_{country} \approx r^{local}_{equity} + r^{FX} \]

A currency-hedged version tries to isolate:

\[ r^{hedged}_{country} \approx r^{local}_{equity} \]

So the difference between unhedged and hedged ETF returns is strongly connected to currency movement:

\[ r^{USD}_{country} - r^{hedged}_{country} \approx r^{FX} \]

For EFA/HEFA and EZU/HEZU, the currency-hedged ETF gives us an economically matched equity exposure, and the currency proxy helps capture the translation component of the unhedged target. The resulting baskets have the ingredients needed to explain much of the target’s return movement over the sample.

14.3 International hedge model results

For several international relationships, we obtain stronger risk reduction than in the main single-stock examples. The strongest cases also have a clear economic interpretation.

EZU/HEZU is the standout. Kalman is selected, with about 91.3% volatility reduction and 91.4% ES reduction. This is an extremely high hedge-quality result. The reason is that unhedged eurozone equity can be decomposed into hedged eurozone equity, euro exposure, and global equity risk. Once HEZU, FXE, and SPY are in the hedge basket, little unexplained broad risk remains.

EFA/HEFA is also very strong, with static selected and about 85.4% volatility reduction and 87.2% ES reduction. Static working here is meaningful. It means the relationship between unhedged developed ex-US equity, currency-hedged developed ex-US equity, and dollar exposure is stable enough that a fixed beta already performs well.

EWJ/DXJ reaches about 79.0% volatility reduction with Kalman selected. Japan has a clean currency-hedged/unhedged structure, and the yen ETF helps absorb the currency component. The Kalman selection suggests the currency and equity-beta relationship isn’t constant through time, which makes sense because yen behavior changes across risk-off, low-rate, and policy-divergence regimes.

FXI macro is also strong, with Kalman selected and around 79.8% volatility reduction. The hedge basket decomposes FXI’s China equity exposure across several related markets and styles. MCHI, KWEB, ASHR, and UUP together explain a large amount of FXI’s movement because they capture broad China, internet/ADR-heavy China exposure, A-share exposure, and dollar pressure.

The weaker relationships are economically interesting:

  • EWZ Brazil has only about 27.7% volatility reduction, even with ridge selected.
  • EWW Mexico has about 28.9% volatility reduction, with Kalman selected.
  • INDA India is around 32.9%, with rolling selected.

These markets contain local risk that broad proxies don’t fully capture. Brazil has commodity exposure, EM beta, currency pressure, local politics, and fiscal risk all mixed together. Mexico has strong US linkage but also local policy and currency behavior. India often has strong domestic growth and flow dynamics that don’t map cleanly to EEM, QQQ, and UUP.

The weaker scores leave us with sizeable residual exposure after the global and regional proxies have been applied. Those returns can still contain country-specific sector weights, currency moves, political risks, and local earnings cycles.

Across the international relationships, we get a different model ranking from the main universe. Rolling has the highest median score, around 0.584, and Kalman is extremely close at 0.578. Kalman wins the most relationships, with 6 wins, while rolling wins 2 and ridge wins 3.

In the main universe, ridge had the highest median score. In the international universe, Kalman becomes more competitive as country exposures and currency components evolve through time. Exchange-rate regimes, commodity cycles, and regional equity betas change, giving a time-varying hedge ratio room to follow those movements.

Looking at the achieved risk reductions, we can group the relationships into a useful economic hierarchy:

  • currency-hedged/unhedged relationships are highly hedgeable: EZU/HEZU, EFA/HEFA, EWJ/DXJ,
  • close China proxy relationships are highly hedgeable: FXI macro,
  • commodity-linked developed markets are moderately hedgeable: Canada and Australia,
  • emerging markets with local/political/currency residuals are harder: Brazil, Mexico, India.

The same hedge comparison gives us a way to study global ETF structure: the amount of remaining risk depends on how closely each basket matches the target’s equity, currency, and commodity exposures.

14.4 Country residuals as economic signals

The lower-quality international hedges leave more risk in the residual. We can interpret that remaining movement by looking at the local exposures that broad equity, commodity, and currency proxies don’t fully capture.

Brazil is a good example. EWZ is linked to EM beta, commodities, rates, the dollar, local politics, fiscal risk, and the Brazilian real. DBC and UUP help, but they don’t fully capture Petrobras, Vale, local policy, and idiosyncratic country cycles. A low hedge score here is economically believable.

Mexico is also distinct. EWW has US supply-chain and nearshoring exposure, but it also has local currency, rates, financials, and political risk. SPY, EEM, and UUP explain part of it but leave meaningful residual structure.

India is another special case. INDA has EM and growth exposure, but India’s domestic earnings cycle, policy environment, and foreign-flow dynamics often behave differently from broad EM. So a moderate hedge result is more realistic than an artificially high one.

The model scores and residual returns help us compare how closely each market can be replicated by its basket. They also give us a starting point for interpreting the local risks that remain after the common proxies have been hedged.

14.5 International residual gates

We apply the international residual gate to five economically related pairs:

\[ \text{EFA/HEFA},\quad \text{EWJ/DXJ},\quad \text{EZU/HEZU},\quad \text{FXI/MCHI},\quad \text{EWT/EWY} \]

Each pair has a financial relationship that gives us a reason to examine a possible long-run equilibrium:

  • EFA and HEFA are unhedged and currency-hedged versions of developed ex-US equity.
  • EWJ and DXJ are unhedged and currency-hedged Japan equity structures.
  • EZU and HEZU are unhedged and currency-hedged eurozone equity structures.
  • FXI and MCHI are close China equity proxies with different index composition.
  • EWT and EWY represent Taiwan and Korea, two semiconductor-heavy Asian equity markets.

Static log-OLS fails for all of them. That means a fixed price spread isn’t stationary enough over the full sample. This is common for international ETFs because currencies, country leadership, and regional growth stories can drift for years.

Rolling log-OLS and Kalman log versions pass the stationarity-style gates in several cases. For example:

  • EFA/HEFA rolling has ADF p-value around 0.0028 and half-life around 54.5 days,
  • EWJ/DXJ rolling has ADF p-value around 0.0025 and half-life around 44.3 days,
  • EZU/HEZU rolling has ADF p-value around 0.0022 and half-life around 52.5 days,
  • FXI/MCHI rolling has ADF p-value around 0.0001 and half-life around 30.3 days,
  • EWT/EWY rolling has ADF p-value around 0.0010 and half-life around 46.6 days.

These half-lives are plausible for swing-style residual trades. But passing the gate still doesn’t guarantee a profitable strategy. The test measures the spread’s mean-reverting structure in the sample, while the result of a trade also depends on the chosen entry and exit levels, transaction costs, and any changes in the relationship during the holding period.

14.6 International residual trading and final plot

The selected international residual strategies produce different return paths. We can compare their net returns, volatility, and drawdowns to see how the same spread-trading rules behaved across the country and currency relationships.

FXI/MCHI rolling log-OLS is the best secondary residual trade. It has about 15.4% net return, annualized volatility around 4.7%, Sharpe around 0.36, and max drawdown around -10.5%. The economic story is reasonable. FXI and MCHI are both China equity exposures, but they differ in holdings, offshore/onshore composition, index construction, and sensitivity to internet/platform names. Those differences can create temporary deviations without becoming totally unrelated assets.

EZU/HEZU is modestly positive, with net return around 6.5% and Sharpe around 0.17. That modest return is consistent with the idea that eurozone unhedged versus hedged exposure plus euro movement can create mean-reverting residuals, but the edge isn’t large after costs.

EFA/HEFA and EWJ/DXJ are roughly flat to slightly negative. Both relationships provide excellent risk reduction in the hedge comparison, while their selected spread trades produce roughly flat to slightly negative results. A close hedge can leave a spread with limited opportunity after costs, or a spread that returns toward its mean too slowly for the trading rule.

EWT/EWY is the clear failure, with net return around -36.3%, Sharpe around -0.38, and max drawdown near -48.6%. Taiwan and Korea share semiconductor and EM exposure, but their residual spread can trend when one market structurally outperforms the other. In the recent AI and semiconductor cycle, Taiwan-related exposure could dominate Korea for reasons a simple mean-reversion rule doesn’t capture.

In the final 3-by-3 comparison, we can bring together the hedge and residual-trading results:

  • high hedge scores appear in currency-hedged and close-proxy relationships,
  • the risk-reduction scatter confirms that most international hedges reduce volatility and ES substantially,
  • the residual NAV panel shows that residual trading is much more selective than hedging,
  • FXI/MCHI works reasonably, EZU/HEZU is mild, and EWT/EWY is a warning that statistical eligibility isn’t enough when the economic spread can trend.