Files
2026-07-13 13:26:28 +08:00

637 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ---
# jupyter:
# jupytext:
# cell_metadata_filter: tags,-all
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.19.3
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# # Futures Positioning: CFTC Commitment of Traders Analysis
#
# **Chapter 4: Fundamental and Alternative Data**
# **Docker image**: `ml4t`
# **Section Reference**: See Section 4.3 for cross-asset fundamentals concepts
#
# ## Purpose
#
# This notebook demonstrates how to access CFTC Commitment of Traders (COT) data
# for tracking institutional positioning in futures markets. COT reports provide
# weekly snapshots of trader positioning, offering valuable sentiment signals for
# futures trading strategies and contrarian indicators.
#
# ## Learning Objectives
#
# After completing this notebook, you will be able to:
# - Understand COT report structure and trader categories
# - Fetch COT data for futures products using ml4t-data
# - Calculate net positioning and z-scores
# - Identify extreme positioning for contrarian signals
# - Combine COT with price data for strategy features
#
# ## Cross-References
#
# - **Upstream**: `data/futures/positioning/cot_download.py` (CFTC COT reports, weekly, free)
# - **Downstream**: Chapter 8 `futures_features.py`, Chapter 16 futures strategies
# - **Related**: `macro_data_alignment.py` (macro timing signals)
#
# ## Why COT Data Matters
#
# COT data is **free** and provides unique insight into:
# - **Institutional positioning**: What are hedge funds/asset managers doing?
# - **Commercial hedging**: Are producers/consumers unusual in their hedging?
# - **Crowd behavior**: Are speculators too extreme (contrarian signal)?
#
# ---
# %%
"""Futures Positioning: CFTC Commitment of Traders Analysis — track institutional positioning for contrarian signals."""
import warnings
warnings.filterwarnings("ignore")
# Visualization
import plotly.graph_objects as go
import polars as pl
from plotly.subplots import make_subplots
from utils.style import COLORS
# %% tags=["parameters"]
# Production defaults — Papermill injects overrides for CI
# %%
def keep_largest_contract(df: pl.DataFrame) -> pl.DataFrame:
"""Drop duplicate (product, report_date) rows.
The CFTC publishes both 'Futures Only' and 'Combined Futures+Options'
panels for each product; the bulk downloader writes both into the
same parquet, leaving two rows per report_date. The combined panel
has materially larger open interest and is the canonical series used
for positioning analysis — keep that one.
"""
return (
df.sort(["product", "report_date", "open_interest"], descending=[False, False, True])
.unique(subset=["product", "report_date"], keep="first")
.sort("report_date")
)
# %% [markdown]
# ---
#
# ## Section 1: Understanding COT Reports
#
# The CFTC publishes weekly COT reports, showing positions as of Tuesday. Reports are
# generally released Friday at **3:30 PM ET** (with holiday delays). For daily-bar
# backtests, a conservative approach is to treat the information as usable from the
# next trading day (+6 calendar days from Tuesday).
#
# There are two main report formats relevant for trading:
#
# ### Report Types
#
# | Report | Coverage | Key Categories |
# |--------|----------|----------------|
# | **TFF** (Traders in Financial Futures) | Financial futures | Dealers, Asset Managers, Leveraged Money |
# | **Disaggregated** | Commodity futures | Commercials, Managed Money, Swap Dealers |
#
# ### Trader Categories
#
# **Financial Futures (TFF):**
# - **Dealers/Intermediaries**: Banks, swap dealers (market makers)
# - **Asset Managers**: Pension funds, mutual funds (institutional)
# - **Leveraged Money**: Hedge funds, CTAs (speculators)
# - **Other Reportables**: Other large traders
# - **Non-Reportables**: Small traders (retail)
#
# **Commodity Futures (Disaggregated):**
# - **Commercials (Producer/Merchant)**: Physical commodity hedgers
# - **Swap Dealers**: Financial intermediaries
# - **Managed Money**: Hedge funds, CTAs
# - **Other Reportables**: Other large traders
#
# ### Key Insight
#
# **Commercials** hedge their physical exposure (informed), while **Speculators**
# (Leveraged Money, Managed Money) bet on direction. Extreme speculator positioning
# often precedes reversals.
# %% [markdown]
# ---
#
# ## Section 2: ml4t-data COT Module
# %%
from ml4t.data.cot import PRODUCT_MAPPINGS
from data.futures.loader import load_cot
print(f"Supported products: {len(PRODUCT_MAPPINGS)}")
# %%
categories = {
"Equity Index": ["ES", "NQ", "RTY", "YM"],
"Currency": ["6E", "6J", "6B", "6C", "6A"],
"Interest Rate": ["ZN", "ZB", "ZF", "ZT"],
"Energy": ["CL", "NG", "RB", "HO"],
"Metals": ["GC", "SI", "HG", "PL"],
"Agricultural": ["ZC", "ZW", "ZS", "ZM", "ZL"],
"Crypto": ["BTC", "ETH"],
"Volatility": ["VX"],
}
for category, products in categories.items():
available = [p for p in products if p in PRODUCT_MAPPINGS]
print(f"{category}:")
for p in available:
info = PRODUCT_MAPPINGS[p]
print(f" {p:5} {info.description} ({info.report_type[:4]})")
print()
# %% [markdown]
# ---
#
# ## Section 3: Loading COT Data
#
# COT data is downloaded by ``data/futures/positioning/cot_download.py`` into
# ``$ML4T_DATA_PATH/futures/positioning/cot/{PRODUCT}.parquet`` and loaded here via
# ``load_cot()``.
# %%
es_cot = keep_largest_contract(
load_cot(products=["ES"], start_date="2020-01-01", end_date="2024-12-31")
)
print(f"Shape: {es_cot.shape}")
print(f"Date range: {es_cot['report_date'].min()} to {es_cot['report_date'].max()}")
print(f"Columns: {es_cot.columns}")
es_cot.head()
# %% [markdown]
# ### COT Data Columns
#
# | Column | Description | Signal |
# |--------|-------------|--------|
# | `open_interest` | Total contracts outstanding | Liquidity, conviction |
# | `lev_money_long` | Hedge fund long positions | Speculator sentiment |
# | `lev_money_short` | Hedge fund short positions | Speculator sentiment |
# | `lev_money_net` | Long - Short | Net speculator positioning |
# | `asset_mgr_net` | Asset manager net | Institutional positioning |
# | `dealer_net` | Dealer net | Market maker flow |
# %%
cl_cot = keep_largest_contract(
load_cot(products=["CL"], start_date="2020-01-01", end_date="2024-12-31")
)
# Disaggregated reports use `managed_money_*` instead of `lev_money_*`;
# standardize for downstream analysis.
cl_cot = cl_cot.with_columns(pl.col("managed_money_net").alias("lev_money_net"))
print(f"Shape: {cl_cot.shape}")
print(f"Columns (note different categories): {cl_cot.columns}")
cl_cot.head()
# %% [markdown]
# ---
#
# ## Section 4: Positioning Analysis
#
# Calculate z-scores to identify extreme positioning.
# %%
def calculate_positioning_zscore(
df: pl.DataFrame,
net_column: str,
window: int = 52, # 1 year of weekly data
) -> pl.DataFrame:
"""
Calculate z-score of net positioning.
Z-scores help identify when positioning is extreme relative to history:
- Z > 2: Very bullish (potentially overbought)
- Z < -2: Very bearish (potentially oversold)
"""
return df.with_columns(
[
pl.col(net_column).rolling_mean(window).alias(f"{net_column}_mean"),
pl.col(net_column).rolling_std(window).alias(f"{net_column}_std"),
]
).with_columns(
[
# Z-score with guard against zero std
pl.when(pl.col(f"{net_column}_std") > 0)
.then((pl.col(net_column) - pl.col(f"{net_column}_mean")) / pl.col(f"{net_column}_std"))
.otherwise(0.0)
.alias(f"{net_column}_zscore"),
]
)
# %%
es_with_zscore = calculate_positioning_zscore(es_cot, "lev_money_net")
extreme_long = es_with_zscore.filter(pl.col("lev_money_net_zscore") > 2)
extreme_short = es_with_zscore.filter(pl.col("lev_money_net_zscore") < -2)
print(f"Extreme Long readings (z > 2): {len(extreme_long)}")
print(f"Extreme Short readings (z < -2): {len(extreme_short)}")
es_with_zscore.select(["report_date", "lev_money_net", "lev_money_net_zscore"]).tail(10)
# %% [markdown]
# ---
#
# ## Section 5: Visualizing Positioning
# %%
# Visualize leveraged money positioning
es_pd = es_with_zscore.to_pandas()
# %%
# Build both panels in a SINGLE cell (feedback_split_cell_figure_bug):
# splitting the figure across cells produced a top-panel-only intermediate.
fig = make_subplots(
rows=2,
cols=1,
subplot_titles=(
"E-mini S&P 500: Leveraged Money Net Positioning",
"Positioning Z-Score (52-week rolling)",
),
row_heights=[0.6, 0.4],
vertical_spacing=0.12,
)
# Net positioning
fig.add_trace(
go.Scatter(
x=es_pd["report_date"],
y=es_pd["lev_money_net"],
mode="lines",
name="Lev Money Net",
line=dict(color=COLORS["blue"], width=1.5),
fill="tozeroy",
fillcolor="rgba(46, 64, 87, 0.3)",
),
row=1,
col=1,
)
# Zero line
fig.add_hline(y=0, line_dash="dash", line_color=COLORS["neutral"], row=1, col=1)
# Z-score with color bands
fig.add_trace(
go.Scatter(
x=es_pd["report_date"],
y=es_pd["lev_money_net_zscore"],
mode="lines",
name="Z-Score",
line=dict(color=COLORS["slate"], width=2),
),
row=2,
col=1,
)
# Add shaded extreme zones
fig.add_hrect(y0=2, y1=4, fillcolor=COLORS["negative"], opacity=0.1, row=2, col=1)
fig.add_hrect(y0=-4, y1=-2, fillcolor=COLORS["positive"], opacity=0.1, row=2, col=1)
# Extreme thresholds
fig.add_hline(y=2, line_dash="dot", line_color=COLORS["negative"], row=2, col=1)
fig.add_hline(y=-2, line_dash="dot", line_color=COLORS["positive"], row=2, col=1)
fig.add_hline(y=0, line_dash="dash", line_color=COLORS["neutral"], row=2, col=1)
fig.update_layout(
height=600,
title="ES Leveraged Money: Net Positioning and 52-Week Z-Score",
template="plotly_white",
showlegend=True,
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
)
fig.update_yaxes(title_text="Net Contracts", row=1, col=1)
fig.update_yaxes(title_text="Z-Score", row=2, col=1)
fig.show()
# %% [markdown]
# ---
#
# ## Section 6: COT-Based Trading Signals
#
# COT data can generate several types of signals:
#
# ### Signal Types
#
# | Signal | Description | Typical Use |
# |--------|-------------|-------------|
# | **Contrarian** | Fade extreme positioning | Z-score > 2 = sell signal |
# | **Momentum** | Follow smart money | Commercials positioning |
# | **Divergence** | Commercial vs Speculator | When they disagree |
# | **Extreme Change** | Rapid positioning shift | Large weekly change |
# %%
def add_pit_available_date(df: pl.DataFrame, report_date_col: str = "report_date") -> pl.DataFrame:
"""
Add a conservative point-in-time availability date for COT.
COT positions are as of Tuesday and are generally released Friday at 3:30 PM ET,
with holiday delays. For daily-bar backtests, a conservative approximation is to
treat the data as available on the next business day after the release week.
If you model intraday timestamps, store a datetime availability timestamp instead.
Args:
df: DataFrame with report_date column
report_date_col: Name of the report date column
Returns:
DataFrame with available_date column added
"""
return df.with_columns(
# Conservative: Tuesday -> next Monday (+6 days) ensures we never use Friday same-day.
# Adjust this policy based on your backtest timestamp resolution.
(pl.col(report_date_col) + pl.duration(days=6)).alias("available_date")
)
# %% [markdown]
# ### Generate COT-Based Signals
# Compute contrarian and large-change signals from speculator positioning z-scores.
# %%
def generate_cot_signals(df: pl.DataFrame, speculator_net: str = "lev_money_net") -> pl.DataFrame:
"""
Generate COT-based trading signals.
Note: For backtesting, use available_date (not report_date) to avoid lookahead bias.
"""
# Add PIT-correct available_date
df = add_pit_available_date(df)
# Calculate z-score
df = calculate_positioning_zscore(df, speculator_net)
# Generate signals
return df.with_columns(
[
# Contrarian signal: extreme positioning suggests reversal
pl.when(pl.col(f"{speculator_net}_zscore") > 2)
.then(pl.lit(-1))
.when(pl.col(f"{speculator_net}_zscore") < -2)
.then(pl.lit(1))
.otherwise(pl.lit(0))
.alias("contrarian_signal"),
# Week-over-week positioning change
pl.col(speculator_net).diff().alias(f"{speculator_net}_change"),
]
).with_columns(
[
# Large change signal (>1 std dev of changes)
pl.when(
pl.col(f"{speculator_net}_change")
> pl.col(f"{speculator_net}_change").rolling_std(26)
)
.then(pl.lit(1))
.when(
pl.col(f"{speculator_net}_change")
< -pl.col(f"{speculator_net}_change").rolling_std(26)
)
.then(pl.lit(-1))
.otherwise(pl.lit(0))
.alias("large_change_signal"),
]
)
# %%
es_signals = generate_cot_signals(es_cot)
contrarian_counts = es_signals.group_by("contrarian_signal").len().sort("contrarian_signal")
contrarian_counts
# %% [markdown]
# Recent signals — for backtesting always join on `available_date` (+6 days), not the
# Tuesday `report_date`, so signals never use information published after the bar.
# %%
es_signals.select(
[
"report_date",
"available_date",
"lev_money_net",
"lev_money_net_zscore",
"contrarian_signal",
"large_change_signal",
]
).tail(10)
# %% [markdown]
# ---
#
# ## Section 6.1: Multi-Product Positioning Comparison
#
# Comparing positioning across products reveals cross-asset sentiment.
# %%
gc_cot = keep_largest_contract(
load_cot(products=["GC"], start_date="2020-01-01", end_date="2024-12-31")
)
gc_cot = gc_cot.with_columns(pl.col("managed_money_net").alias("lev_money_net"))
print(f"Shape: {gc_cot.shape}")
# Calculate z-scores for all products
es_z = calculate_positioning_zscore(es_cot, "lev_money_net")
cl_z = calculate_positioning_zscore(cl_cot, "lev_money_net")
gc_z = calculate_positioning_zscore(gc_cot, "lev_money_net")
# %%
# Create multi-product comparison
fig = make_subplots(
rows=3,
cols=1,
subplot_titles=(
"E-mini S&P 500 (ES) - Equity Index",
"Crude Oil (CL) - Energy",
"Gold (GC) - Precious Metals",
),
vertical_spacing=0.08,
shared_xaxes=True,
)
for i, (df, name, color) in enumerate(
[
(es_z.to_pandas(), "ES", COLORS["blue"]),
(cl_z.to_pandas(), "CL", COLORS["amber"]),
(gc_z.to_pandas(), "GC", COLORS["slate"]),
],
1,
):
# Z-score line
fig.add_trace(
go.Scatter(
x=df["report_date"],
y=df["lev_money_net_zscore"],
mode="lines",
name=f"{name} Z-Score",
line=dict(color=color, width=1.5),
),
row=i,
col=1,
)
# Extreme bands
fig.add_hrect(y0=2, y1=4, fillcolor=COLORS["negative"], opacity=0.1, row=i, col=1)
fig.add_hrect(y0=-4, y1=-2, fillcolor=COLORS["positive"], opacity=0.1, row=i, col=1)
fig.add_hline(y=2, line_dash="dot", line_color=COLORS["negative"], opacity=0.5, row=i, col=1)
fig.add_hline(y=-2, line_dash="dot", line_color=COLORS["positive"], opacity=0.5, row=i, col=1)
fig.add_hline(y=0, line_dash="dash", line_color=COLORS["neutral"], opacity=0.5, row=i, col=1)
# %%
fig.update_layout(
height=700,
title="Speculator Positioning Z-Scores Across Asset Classes",
template="plotly_white",
showlegend=True,
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
)
fig.update_yaxes(title_text="Z-Score", range=[-4, 4])
fig.show()
# %%
latest = es_cot.sort("report_date").tail(1)
print(f"Latest Report: {latest['report_date'][0]}")
print("Net Positions by Trader Category:")
print(f" Leveraged Money (Hedge Funds): {latest['lev_money_net'][0]:>12,}")
print(f" Asset Managers (Institutions): {latest['asset_mgr_net'][0]:>12,}")
print(f" Dealers (Market Makers): {latest['dealer_net'][0]:>12,}")
# %%
# Visualize trader categories over time
es_pd = es_cot.to_pandas()
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=es_pd["report_date"],
y=es_pd["lev_money_net"],
mode="lines",
name="Leveraged Money",
line=dict(color=COLORS["blue"], width=2),
)
)
fig.add_trace(
go.Scatter(
x=es_pd["report_date"],
y=es_pd["asset_mgr_net"],
mode="lines",
name="Asset Managers",
line=dict(color=COLORS["slate"], width=2),
)
)
fig.add_trace(
go.Scatter(
x=es_pd["report_date"],
y=es_pd["dealer_net"],
mode="lines",
name="Dealers",
line=dict(color=COLORS["amber"], width=2),
)
)
fig.add_hline(y=0, line_dash="dash", line_color=COLORS["neutral"])
fig.update_layout(
height=450,
title="ES Net Positioning by Trader Category",
template="plotly_white",
xaxis_title="Report Date",
yaxis_title="Net Contracts",
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
)
fig.show()
# %% [markdown]
# **Key Observations:**
# - **Leveraged Money** (hedge funds) shows the most volatility and tends to be trend-following
# - **Asset Managers** are smoother, reflecting longer-term institutional allocations
# - **Dealers** often take the opposite side (market making), providing liquidity
# - When all categories align, it can signal crowded positioning
# %% [markdown]
# ---
#
# ## Section 7: ml4t-data Feature Generation
#
# The ml4t-data library provides utilities to combine COT with price data.
# %%
sample_features = es_signals.select(
[
"report_date",
"available_date",
"lev_money_net_zscore",
"contrarian_signal",
"large_change_signal",
]
).with_columns(
[
# Additional features
pl.col("lev_money_net_zscore").shift(1).alias("zscore_lag1"),
(pl.col("lev_money_net_zscore") - pl.col("lev_money_net_zscore").shift(1)).alias(
"zscore_momentum"
),
]
)
sample_features.tail(10)
# %% [markdown]
# ---
#
# ## Section 8: Summary
#
# ### Key Takeaways
#
# 1. **COT data is free** and provides unique institutional positioning insight
# 2. **Two report types**: TFF (financial) and Disaggregated (commodities)
# 3. **Z-scores** identify extreme positioning for contrarian signals
# 4. **Commercials vs Speculators**: Track the "smart money"
# 5. **Release lag**: Report date is Tuesday, released Friday 3:30 PM - use +6 day lag for daily backtests!
#
# ### Using CoT in the book
#
# Download (one-time, writes per-product parquets to ``$ML4T_DATA_PATH/futures/positioning/cot/``):
#
# ```bash
# python data/futures/positioning/cot_download.py # all products, 2020current
# python data/futures/positioning/cot_download.py --products ES,CL,GC,ZN # subset
# python data/futures/positioning/cot_download.py --start-year 2010 # longer history
# ```
#
# Load in a notebook:
#
# ```python
# from data.futures.loader import load_cot
#
# es_cot = load_cot(products=["ES"], start_date="2020-01-01", end_date="2024-12-31")
# all_cot = load_cot() # everything available locally
# ```
#
# ### Integration with Book
#
# - **Chapter 8**: COT as sentiment feature for financial feature engineering
# - **Chapter 16**: Futures strategy using positioning signals
# - **Chapter 19**: COT in portfolio risk monitoring