Skip to content

COTAnalysis

Victor Kaiuki edited this page Jun 21, 2026 · 3 revisions

COTAnalysis

The COTAnalysis class computes metrics on fetched COT data.

from cftc_cot import COTAnalysis

# df comes from client.execute()
analysis = COTAnalysis(df, classification="legacy")

# Compute
df = analysis.net_positions()
df = analysis.cot_index(window=156)

Note: On construction, rows are sorted ascending by report_date_as_yyyy_mm_dd when that column is present, so rolling and difference-based metrics are correct even though the client returns data newest-first. All methods are chainable in the sense that each enriches and returns the same internal DataFrame.

Methods

Method Description Columns added
.net_positions() Net (long − short) per category. {cat}_net
.z_scores(window=52) Rolling Z-scores of net positions. {cat}_net_zscore
.cot_index(window=156) Classic 0–100 COT Index over a rolling lookback. {cat}_net_cot_index
.cot_index_multi(windows=(26,52,156)) COT Index at several windows (term structure); skips windows > history. {cat}_net_cot_index_w{N}
.extremes(threshold=0.95, window=156, persistence=2) Flags "bullish"/"bearish"/NaN from the COT Index (ramp excluded, persistence-filtered). {cat}_net_extreme
.masking() Gross-vs-net positioning the coarse report hides (disaggregated/tff). Returns a DataFrame.
.long_short_ratios() Long ÷ short per category. {cat}_ls_ratio
.percentile_rank(column) Percentile rank (0–1) of the most recent value. Returns a float.
.wow_change() Week-over-week change in net positions. {cat}_net_wow

Categories depend on the classification:

  • legacy: noncomm, comm
  • disaggregated: prod_merc, swap, m_money, other
  • tff: dealer, asset_mgr, lev_money, other

COT Index

The COT Index normalizes the current net position against its range over a rolling window:

100 * (net - rolling_min) / (rolling_max - rolling_min)

A reading near 100 is the most bullish positioning of the window; near 0, the most bearish. The default window=156 is ~3 years of weekly data, the conventional lookback. Rows without a populated range yet (e.g. the very first observation) are NaN.

df = analysis.cot_index(window=156)
print(df[['report_date_as_yyyy_mm_dd', 'noncomm_net_cot_index']].tail())

Extremes

df = analysis.extremes(threshold=0.95, persistence=2)
# noncomm_net_extreme == "bullish"  when COT index >= 95 for >= 2 consecutive weeks
# noncomm_net_extreme == "bearish"  when COT index <= 5  for >= 2 consecutive weeks

Because the COT Index is a rolling min-max, trending series pin to 0/100 and a bare threshold flags very often. Two guards keep the signal meaningful: the first window rows (an incomplete, degenerate lookback) are never flagged, and persistence requires the reading to hold for N consecutive weeks. Pass persistence=1 to disable the persistence filter.

Changed in 0.5.0: default threshold 0.9 → 0.95; ramp excluded; persistence (default 2) added. Earlier versions flagged ~38% of weeks.

Term structure (multiple windows)

df = analysis.cot_index_multi(windows=(26, 52, 156))
# noncomm_net_cot_index_w26 / _w52 / _w156 — same position, different lookbacks

The same position can read as a short-term extreme but mid-range long-term, so the window choice is itself informative.

Masking (cross-classification)

disaggregated and tff split legacy's coarse categories into finer ones. masking() shows how much positioning the coarse net hides — for ~84% of commodities the legacy "commercial" net is small while producers and swap dealers hold large, offsetting books.

from cftc_cot import COTClient, COTAnalysis
c = COTClient()
df = c.history("disaggregated", "AECO FIN BASIS - ICE FUTURES ENERGY DIV", weeks=120, exact=True)
COTAnalysis(df, "disaggregated").masking()
#  group        components                 net    gross  masking_ratio  components_corr
#  commercial   [prod_merc_net, swap_net]  -40647 1301267  53.8           -0.91

A high masking_ratio with negative components_corr = the headline net is misleading. Returns empty for legacy (no finer split).

Clone this wiki locally