Capex Intent

Composite signal capturing capital‑expenditure momentum through durable goods orders, including ex‑transportation when available.

Abstract

The Capex Intent signal infers forward business investment trends from durable goods order data. It combines year‑over‑year and short‑term (3‑month annualised) growth rates for total and ex‑transport durable orders. When the ex‑transport series (ADXTNO) is present, it is prioritised. The composite detects Expansion, Neutral, or Contraction phases in capital expenditure cycles.

1. Data

Monthly nominal series from the U.S. Census Bureau’s M3 report (via FRED). Level data in USD millions.

2. Transformations

  1. YoY Growth: g_{12} = X_t / X_{t−12} − 1
  2. 3‑Month Annualised Growth: g_{3mAnn} = (X_t / X_{t−3})^4 − 1

The 3‑month annualised rate accelerates detection of inflection points in investment cycles.

3. Normalisation

z_{rob}(X)_t = \dfrac{X_t − median(X)_{t,w}}{1.4826·MAD(X)_{t,w}}
Rolling window w = 48 months (minimum 18). Median absolute deviation scaling reduces distortion from outliers.

4. Composite Construction

Weights adjust automatically depending on data availability:

# If ADXTNO (ex‑transport) is available:
Capex_Intent = 0.4·z(ADXTNO_YoY) + 0.4·z(ADXTNO_3mAnn) + 0.2·z(DGORDER_YoY)
# Otherwise:
Capex_Intent = 0.5·z(DGORDER_YoY) + 0.5·z(DGORDER_3mAnn)

This adaptive weighting ensures emphasis on core durable orders when possible, while retaining comparability across datasets.

5. Regime Classification

If\ C_t > 0.75 → Expansion;\quad |C_t| ≤ 0.75 → Neutral;\quad C_t < −0.75 → Contraction

6. Implementation (Python)

import pandas as pd
import numpy as np

def robust_z(s, win=48, min_win=18):
    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_durables.copy()

ex_col = None
if "ADXTNO" in d.columns:
    ex_col = "ADXTNO"
elif "DGEXORD" in d.columns:
    ex_col = "DGEXORD"

def ann3(series):
    return (series / series.shift(3))**4 - 1.0

target_cols = ["DGORDER"] + ([ex_col] if ex_col else [])
for col in target_cols:
    d[f"{col}_YoY"]   = d[col].pct_change(12)
    d[f"{col}_3mAnn"] = ann3(d[col])

if ex_col:
    d["Z_EX_YoY"]   = robust_z(d[f"{ex_col}_YoY"])
    d["Z_EX_3mAnn"] = robust_z(d[f"{ex_col}_3mAnn"])
    d["Z_ALL_YoY"]  = robust_z(d["DGORDER_YoY"])
    d["Capex_Intent"] = (0.4*d["Z_EX_YoY"] + 0.4*d["Z_EX_3mAnn"] + 0.2*d["Z_ALL_YoY"])
    d["Capex_Sources"] = f"DGORDER + {ex_col}"
else:
    d["Z_ALL_YoY"]   = robust_z(d["DGORDER_YoY"])
    d["Z_ALL_3mAnn"] = robust_z(d["DGORDER_3mAnn"])
    d["Capex_Intent"] = 0.5*d["Z_ALL_YoY"] + 0.5*d["Z_ALL_3mAnn"]
    d["Capex_Sources"] = "DGORDER only"

hi, lo = 0.75, -0.75

def _regime(v):
    if pd.isna(v): return np.nan
    return "Expansion" if v > hi else ("Contraction" if v < lo else "Neutral")

d["Capex_Regime"] = d["Capex_Intent"].apply(_regime)

df_sig_capex = d
print(f"✅ Capex Intent built using: {d['Capex_Sources'].iloc[-1]}")
display(df_sig_capex.tail())

7. Interpretation

8. Limitations