Energy‑Adjusted Metals Tailwind
Abstract
Copper prices embed both global demand impulses and cost/FX drivers. We regress copper on oil (energy input proxy) and the broad USD (pricing/FX headwind) to obtain a residual. A robust z‑score of that residual forms the Metals Tailwind index, mapped to Demand_Tailwind, Neutral, or Headwind regimes.
1. Data
- COPPER_YF_USD_LB — Copper price proxy from
yfinance(HG=F, COMEX copper futures), daily (resample to month‑end). - DCOILWTICO — WTI spot price, daily (resample to month‑end).
- DTWEXBGS — Trade Weighted U.S. Dollar Index: Broad, Goods, daily (resample to month‑end).
Construct df_metals_adj as a monthly panel with columns above before running the signal.
Units note: HG=F is typically quoted in USD per pound. Unit comparability vs USD/MT is not required for this signal because the residual is converted to a robust z‑score (scale‑invariant). If you need unit comparability for other use cases, USD/lb → USD/MT can be converted by multiplying by ~2204.6226.
2. Model
Estimate a simple OLS of copper levels on oil and USD with an intercept, using all available overlapping observations (minimum 24 months). Compute the fitted value and residual:
Levels regression is chosen for simplicity and interpretability; differenced or log specifications are valid alternatives when stationarity is a concern.
3. Normalisation
4. Regime Mapping
5. Implementation (Python)
import pandas as pd
import numpy as np
def robust_z(s, win=60, min_win=24):
x = pd.to_numeric(s, errors="coerce").astype(float)
w = max(min_win, min(win, x.dropna().size))
med = x.rolling(w, min_periods=min_win).median()
mad = (x - med).abs().rolling(w, min_periods=min_win).median()
return (x - med) / (1.4826 * mad.replace(0, np.nan))
d = df_metals_adj.copy()
# Simple monthly OLS of copper on oil & USD (rolling residual level)
X = d[["DCOILWTICO","DTWEXBGS"]].copy()
X = X.assign(const=1.0)
y = d["COPPER_YF_USD_LB"]
mask = X.join(y).dropna().index
if len(mask) >= 24:
Xn = X.loc[mask, ["const","DCOILWTICO","DTWEXBGS"]].values
yn = y.loc[mask].values
beta = np.linalg.pinv(Xn.T @ Xn) @ (Xn.T @ yn)
d.loc[mask, "Copper_Fitted"] = (Xn @ beta)
d["Copper_Residual"] = d["COPPER_YF_USD_LB"] - d["Copper_Fitted"]
else:
d["Copper_Residual"] = np.nan
d["Metals_Tailwind"] = robust_z(d["Copper_Residual"])
hi, lo = 0.75, -0.75
def _regime(v):
if pd.isna(v): return np.nan
return "Demand_Tailwind" if v > hi else ("Headwind" if v < lo else "Neutral")
d["Metals_Regime"] = d["Metals_Tailwind"].apply(_regime)
df_sig_metals = d
display(df_sig_metals.tail())
If you previously used PCOPPUSDM, replace it with COPPER_YF_USD_LB throughout.
6. Visualisation
Plot copper vs fitted copper on the primary axis, and the residual z‑score on a secondary axis. For long‑horizon context, the standard implementation plots the last 30 years (where available).
- Primary axis: COPPER_YF_USD_LB vs Copper_Fitted
- Secondary axis: Metals_Tailwind with thresholds at
±0.75 - Optional regime shading using Metals_Regime
7. Notes & Extensions
- Specification: Consider logs and/or first differences to reduce heteroskedasticity; re‑estimate betas in a rolling window if time‑variation is expected.
- Broader metals basket: Adapt the approach to aluminum, nickel, or a composite LME index.
- FX controls: Replace broad USD with trade‑weighted EM FX for China/EM demand tilt.
- Alternative copper proxies: Substitute a spot or LME series by replacing the copper column while keeping the OLS + robust z framework intact.