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

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:

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

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