commodisense / data /collector_cot.py
Yash1178's picture
Add data/ and model/ source directories
df6b63c
Raw
History Blame Contribute Delete
6.42 kB
"""
CFTC Commitment of Traders (COT) Collector — disaggregated futures report.
No API key required. Data is published weekly (Friday 3:30 PM ET).
Usage:
python data/collector_cot.py # YTD + current year
python data/collector_cot.py --backfill # last 5 years
"""
import io
import logging
import sys
import zipfile
from datetime import date
from pathlib import Path
import pandas as pd
import requests
sys.path.insert(0, str(Path(__file__).parent.parent))
from data.db import get_conn
log = logging.getLogger(__name__)
# Lowercase partial matches → symbol (longer keys first avoids substring collisions)
COT_NAME_MAP = {
"crude oil, light sweet": "CL=F",
"natural gas (nymex)": "NG=F", # pre-2022 CFTC naming
"henry hub penultimate": "NG=F", # NYMEX Henry Hub variant
"nat gas nyme": "NG=F", # 2022+ NYMEX Natural Gas
"gold": "GC=F",
"copper- #1": "HG=F", # actual CFTC name (not "copper-grade")
"wheat-srw": "ZW=F",
"corn": "ZC=F",
"soybeans": "ZS=F",
"cotton no. 2": "CT=F",
"sugar no. 11": "SB=F",
}
# Ordered from most- to least-specific so "corn" doesn't match "acorn" etc.
_SORTED_KEYS = sorted(COT_NAME_MAP.keys(), key=len, reverse=True)
REQUIRED_COLS = [
"Market_and_Exchange_Names",
"As_of_Date_In_Form_YYYY-MM-DD",
"Open_Interest_All",
"Prod_Merc_Positions_Long_All",
"Prod_Merc_Positions_Short_All",
"M_Money_Positions_Long_All",
"M_Money_Positions_Short_All",
]
ALT_DATE_COL = "Report_Date_as_YYYY-MM-DD"
def _fetch_zip(url: str) -> pd.DataFrame:
try:
resp = requests.get(url, timeout=90)
resp.raise_for_status()
except requests.RequestException as exc:
log.warning("COT download failed (%s): %s", url, exc)
return pd.DataFrame()
try:
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
txt_files = [f for f in zf.namelist() if f.lower().endswith(".txt")]
if not txt_files:
log.warning("No .txt in zip: %s", url)
return pd.DataFrame()
with zf.open(txt_files[0]) as f:
return pd.read_csv(f, low_memory=False)
except Exception as exc:
log.warning("COT zip parse error: %s", exc)
return pd.DataFrame()
def _match_symbol(name: str) -> str | None:
lower = name.lower()
for key in _SORTED_KEYS:
if key in lower:
return COT_NAME_MAP[key]
return None
def _parse_and_store(raw: pd.DataFrame) -> int:
if raw.empty:
return 0
raw.columns = raw.columns.str.strip()
# Normalise date column — CFTC changed formats across years:
# Modern (2013+): As_of_Date_In_Form_YYYY-MM-DD
# Legacy (2010-2012): As_of_Date_In_Form_YYMMDD or Report_Date_as_MM_DD_YYYY
DATE_COL = "As_of_Date_In_Form_YYYY-MM-DD"
if DATE_COL not in raw.columns:
for fallback in [ALT_DATE_COL, "As_of_Date_In_Form_YYMMDD", "Report_Date_as_MM_DD_YYYY"]:
if fallback in raw.columns:
raw = raw.rename(columns={fallback: DATE_COL})
break
missing = [c for c in REQUIRED_COLS if c not in raw.columns]
if missing:
log.error("COT missing columns: %s (available: %s)", missing, list(raw.columns[:10]))
return 0
df = raw[REQUIRED_COLS].copy()
df.columns = ["market_name", "date", "open_interest",
"comm_long", "comm_short", "mm_long", "mm_short"]
df["symbol"] = df["market_name"].apply(_match_symbol)
df = df[df["symbol"].notna()].copy()
if df.empty:
return 0
df["date"] = pd.to_datetime(df["date"], errors="coerce").dt.date
df = df.dropna(subset=["date"])
for col in ["open_interest", "comm_long", "comm_short", "mm_long", "mm_short"]:
df[col] = pd.to_numeric(
df[col].astype(str).str.replace(",", ""), errors="coerce"
).fillna(0)
df = df.sort_values(["symbol", "date"]).reset_index(drop=True)
df["commercial_net_long"] = df["comm_long"] - df["comm_short"]
df["mm_net_long"] = df["mm_long"] - df["mm_short"]
df["commercial_net_pct"] = df["commercial_net_long"] / df["open_interest"].replace(0, 1)
df["mm_net_pct"] = df["mm_net_long"] / df["open_interest"].replace(0, 1)
df["commercial_chg_1w"] = df.groupby("symbol")["commercial_net_long"].diff()
df["mm_chg_1w"] = df.groupby("symbol")["mm_net_long"].diff()
conn = get_conn()
inserted = 0
for _, row in df.iterrows():
try:
conn.execute("""
INSERT OR REPLACE INTO cot_data
(date, symbol, commercial_net_long, commercial_net_pct,
mm_net_long, mm_net_pct, commercial_chg_1w, mm_chg_1w, open_interest)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", [
row["date"], row["symbol"],
row["commercial_net_long"], row["commercial_net_pct"],
row["mm_net_long"], row["mm_net_pct"],
None if pd.isna(row["commercial_chg_1w"]) else row["commercial_chg_1w"],
None if pd.isna(row["mm_chg_1w"]) else row["mm_chg_1w"],
row["open_interest"],
])
inserted += 1
except Exception as exc:
log.debug("COT insert error: %s", exc)
conn.close()
return inserted
def run(backfill: bool = False) -> str:
current_year = date.today().year
total = 0
# CFTC disaggregated report starts 2009. Full history = more market cycles to learn from.
start_year = 2009 if backfill else current_year
for year in range(start_year, current_year + 1):
url = f"https://www.cftc.gov/files/dea/history/fut_disagg_txt_{year}.zip"
raw = _fetch_zip(url)
n = _parse_and_store(raw)
log.info("COT %d: %d rows", year, n)
total += n
return f"COT: stored {total} rows"
if __name__ == "__main__":
import argparse
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
parser = argparse.ArgumentParser()
parser.add_argument("--backfill", action="store_true")
args = parser.parse_args()
from data.db import init_schema
init_schema()
print(run(backfill=args.backfill))