Macro Themes from CoT

Daily macro-theme classifier built from sector-level CoT positioning, flows, and hedger behaviour. Identifies Inflation Impulse, Disinflation, Global Slowdown, and Supply Stress regimes from futures positioning.

Abstract

This signal maps cross-market CoT positioning into intuitive macro themes. Using sector classifications (Energy, Metals, Ags, FX, Rates), speculative crowding, and hedger deep-short/deep-long behaviour, it defines four daily themes: Inflation Impulse, Disinflation, Global Slowdown, and Supply Stress. Each theme is triggered when specific sector-level breadth conditions are met, and the results are stored as boolean flags and an aggregated Active_Macro_Themes list.

1. Inputs

2. Sector Classification

Markets are mapped into macro sectors using metadata first and name-based heuristics second. Sectors are consistent with other CoT sector signals:

This classification is used downstream to pivot speculative crowding and hedger pressure into sector-wide time series.

3. Hedger Deep Short / Long Flags

Commercial hedger extremes are defined from their z-scores:

hedger\_deep\_short = Hedger\_zscore ≤ −1.0
hedger\_deep\_long = Hedger\_zscore ≥ +1.0

Deep shorts indicate aggressive supply hedging (e.g., producers selling forward), while deep longs indicate shortage or stress in physical markets (e.g., users hedging consumption risk).

4. Sector-Level Aggregation

For each sector and report date, the following breadth metrics are computed:

These are then pivoted into sector-wide time series (Energy, Metals, Ags, etc.) to enable sector-specific threshold logic.

5. Overall Risk-On / Risk-Off Breadth

Separately from sector detail, an overall Risk-On / Risk-Off tone is computed from global crowding:

crowded\_long\_share = crowded\_long\_count / n\_markets
crowded\_short\_share = crowded\_short\_count / n\_markets
net\_tone = crowded\_long\_share − crowded\_short\_share

This aligns the macro theme logic with a broad Risk-On / Risk-Off backdrop.

6. Theme Construction

Four macro themes are defined by combining sector breadth and overall tone thresholds. Each is a boolean flag per day:

Finally, an Active_Macro_Themes string is built per date by listing all active themes (or "None" if no conditions are met).

7. Implementation (Python)

df_theme = df_cot_signals.copy()
df_theme["Report_Date"] = pd.to_datetime(df_theme["Report_Date"], errors="coerce")

# Sector classification
if "Sector" not in df_theme.columns:
    df_theme["Sector"] = df_theme.apply(_classify_sector_theme, axis=1)

# Hedger deep flags
hz = pd.to_numeric(df_theme["Hedger_zscore"], errors="coerce")
HEDGER_DEEP_SHORT_Z = -1.0
HEDGER_DEEP_LONG_Z  =  1.0

df_theme["hedger_deep_short"] = hz <= HEDGER_DEEP_SHORT_Z
    
df_theme["hedger_deep_long"]  = hz >= HEDGER_DEEP_LONG_Z

# Sector-level aggregation
group_cols = ["Report_Date", "Sector"]

df_sector_theme = (
    df_theme
    .groupby(group_cols)
    .agg(
        n_markets=("Market_and_Exchange_Names", "nunique"),
        spec_crowded_long_share=("crowded_long", "mean"),
        spec_crowded_short_share=("crowded_short", "mean"),
        hedger_deep_short_share=("hedger_deep_short", "mean"),
        hedger_deep_long_share=("hedger_deep_long", "mean"),
        avg_spec_zscore=("Spec_zscore", "mean"),
        avg_hedger_zscore=("Hedger_zscore", "mean"),
    )
    .reset_index()
)

# Wide sector views
cl = df_sector_theme.pivot(index="Report_Date", columns="Sector", values="spec_crowded_long_share")
cs = df_sector_theme.pivot(index="Report_Date", columns="Sector", values="spec_crowded_short_share")
hd_short = df_sector_theme.pivot(index="Report_Date", columns="Sector", values="hedger_deep_short_share")
hd_long  = df_sector_theme.pivot(index="Report_Date", columns="Sector", values="hedger_deep_long_share")

# Helper to get sector columns
def _get_col(df, col_name):
    if df is None or col_name not in df.columns:
        return pd.Series(np.nan, index=df.index if df is not None else None)
    return df[col_name]

cl_energy   = _get_col(cl, "Energy")
cl_metals   = _get_col(cl, "Metals")
cl_ags      = _get_col(cl, "Ags")

cs_metals   = _get_col(cs, "Metals")
cs_energy   = _get_col(cs, "Energy")
cs_ags      = _get_col(cs, "Ags")

hd_short_energy = _get_col(hd_short, "Energy")
hd_short_metals = _get_col(hd_short, "Metals")
hd_short_ags    = _get_col(hd_short, "Ags")

hd_long_energy  = _get_col(hd_long, "Energy")
hd_long_ags     = _get_col(hd_long, "Ags")

# Overall breadth
df_overall = (
    df_theme
    .groupby("Report_Date")
    .agg(
        n_markets=("Market_and_Exchange_Names", "nunique"),
        crowded_long_count=("crowded_long", "sum"),
        crowded_short_count=("crowded_short", "sum"),
    )
    .reset_index()
)

df_overall["n_markets"] = df_overall["n_markets"].replace(0, np.nan)
df_overall["crowded_long_share"]  = df_overall["crowded_long_count"]  / df_overall["n_markets"]
df_overall["crowded_short_share"] = df_overall["crowded_short_count"] / df_overall["n_markets"]
df_overall["net_tone"] = df_overall["crowded_long_share"] - df_overall["crowded_short_share"]

# Align indices
idx = cl.index.union(df_overall.set_index("Report_Date").index)


df_overall = df_overall.set_index("Report_Date").reindex(idx)
cl_energy   = cl_energy.reindex(idx)
cl_metals   = cl_metals.reindex(idx)
cl_ags      = cl_ags.reindex(idx)
cs_metals   = cs_metals.reindex(idx)
hd_short_energy = hd_short_energy.reindex(idx)
hd_short_metals = hd_short_metals.reindex(idx)
hd_short_ags    = hd_short_ags.reindex(idx)
hd_long_energy  = hd_long_energy.reindex(idx)
hd_long_ags     = hd_long_ags.reindex(idx)

# Theme flags
df_macro_themes_daily = pd.DataFrame(index=idx)
df_macro_themes_daily.index.name = "Report_Date"

cl_total = df_overall["crowded_long_share"]
cs_total = df_overall["crowded_short_share"]
net_tone = df_overall["net_tone"]

# Inflation impulse
df_macro_themes_daily["Inflation_Impulse"] = (
    (cl_energy >= 0.40) &
    (cl_metals >= 0.30) &
    ((hd_short_energy >= 0.20) | (hd_short_metals >= 0.20))
)

# Disinflation
df_macro_themes_daily["Disinflation"] = (
    (cl_total <= 0.20) &
    (cs_total <= 0.25) &
    (hd_short_energy <= 0.20) &
    (hd_short_metals <= 0.20)
)

# Global slowdown
risk_off_cluster = (
    (net_tone <= -0.20) &
    (cs_total >= 0.30)
)


df_macro_themes_daily["Global_Slowdown"] = (
    (cs_metals >= 0.35) & risk_off_cluster
)

# Supply stress
df_macro_themes_daily["Supply_Stress"] = (
    (hd_long_energy >= 0.20) | (hd_long_ags >= 0.20)
)

# Active theme list
theme_cols = ["Inflation_Impulse", "Disinflation", "Global_Slowdown", "Supply_Stress"]


def _active_theme_list(row: pd.Series) -> str:
    active = [name for name in theme_cols if bool(row.get(name, False))]
    return ", ".join(active) if active else "None"


df_macro_themes_daily["Active_Macro_Themes"] = df_macro_themes_daily.apply(_active_theme_list, axis=1)

# Column-based frame

df_macro_themes_daily = df_macro_themes_daily.reset_index()

8. Interpretation

9. Limitations

10. Practical Use Cases