Energy‑Adjusted Metals Tailwind

Signal that extracts the copper‑specific demand residual after controlling for energy (WTI oil) and the broad U.S. dollar. Residual strength indicates a metals demand 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

This signal combines one copper price proxy with two macro controls (energy input costs and broad USD). All three series are aligned to a month-end calendar (resampled from daily) before estimation.

Series Provider What it represents Units / frequency
COPPER_YF_USD_LB
Ticker: HG=F
Yahoo Finance via yfinance COMEX copper futures price proxy (front-month). Used as a liquid, market-based “industrial metals” signal. USD per lb (typical); daily, resampled to month-end
DCOILWTICO FRED (EIA) West Texas Intermediate crude oil spot price. Used as an energy input-cost proxy that co-moves with industrial cycles and cost pressures. USD per barrel; daily, resampled to month-end
DTWEXBGS FRED (Federal Reserve) Broad trade-weighted U.S. dollar index (goods). Captures the global USD pricing/FX headwind that often co-moves inversely with USD-denominated commodity prices. Index level; daily, resampled to month-end

Data handling & quality notes

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:

Copperₜ = β₀ + β₁·Oilₜ + β₂·USDₜ + εₜ
Copper_Residualₜ = Copperₜ − Copper_Fittedₜ

Levels regression is chosen for simplicity and interpretability; differenced or log specifications are valid alternatives when stationarity is a concern.

3. Normalisation

zᵣₒᵦ(ε)ₜ = (εₜ − median(ε)ₜ,ᵥ) / (1.4826 × MAD(ε)ₜ,ᵥ)
Rolling window v = 60 months (minimum 24) to capture medium‑term deviations.

4. Regime Mapping

If Cₜ > 0.75 → Demand_Tailwind · |Cₜ| ≤ 0.75 → Neutral · Cₜ < −0.75 → Headwind

4A. Model Interpretation

The signal is designed to separate “copper-specific” price strength from two common macro drivers: (1) energy input costs (oil) and (2) broad USD strength. The Copper_Residual is the portion of copper’s level not explained by these controls in the fitted regression, and Metals_Tailwind is a robust z-score of that residual.

  • Positive tailwind (z > +0.75): copper is “rich” versus what would be expected from oil and USD. This often aligns with stronger underlying metals demand (industrial activity, inventory tightness, or supply constraints) than the controls alone would imply.
  • Neutral (|z| ≤ 0.75): copper is broadly consistent with oil and USD; treat as low-conviction for the metals-demand residual.
  • Headwind (z < −0.75): copper is “cheap” versus what would be expected from oil and USD, consistent with softer metals demand or adverse idiosyncratic copper dynamics.

How to use in a macro stack

Key interpretation caveats

Academic & policy context (selected)

See references in Section 9.

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).

7. Notes & Extensions

8. Future Enhancements (Out of Scope)

9. References (selected)