COT positioning data tells you what the largest speculative participants in currency futures markets are doing with real money — every week, without interpretation. When you align your trade entries with the directional bias of non-commercial positioning, you add a meaningful filter that separates high-probability setups from trades that are swimming against informed institutional flow.
This guide walks through the complete process: pulling COT data from the FXMacroData API, calculating the key derived metrics, building a positioning filter, and applying it to your entry workflow. By the end, you will have a working Python-based filter you can slot into any FX strategy.
What You Will Build
- A Python function that fetches weekly COT data for any of the eight supported currencies
- A net-positioning normalisation metric (net as % of open interest)
- A multi-condition positioning filter with configurable thresholds
- A trade entry gate that returns a directional signal:
long,short, orneutral - A practical walkthrough using EUR/USD as the worked example
Prerequisites
- FXMacroData API key — available at /subscribe. The COT endpoint is included in all paid plans.
- Python 3.9+ with the
requestslibrary installed (pip install requests). - Basic familiarity with COT report terminology (non-commercial longs, shorts, open interest). If you need background, the COT Report Guide for FX Traders covers the fundamentals.
- Optionally,
pandasfor the data manipulation steps (pip install pandas).
Step 1 — Fetch COT Data from the API
The FXMacroData COT endpoint returns weekly non-commercial and commercial positioning for currency futures. Supported currencies are AUD, CAD, CHF, EUR, GBP, JPY, NZD, and USD. Each record contains the long, short, and net contract count for non-commercial and commercial participants, plus total open interest.
curl "https://fxmacrodata.com/api/v1/cot/eur?api_key=YOUR_API_KEY&start=2023-01-01"
The response JSON has this structure:
{
"currency": "eur",
"data": [
{
"date": "2025-03-25",
"noncommercial_long": 198432,
"noncommercial_short": 61840,
"noncommercial_net": 136592,
"commercial_long": 68230,
"commercial_short": 201860,
"open_interest": 591400
},
{
"date": "2025-03-18",
"noncommercial_long": 185710,
"noncommercial_short": 66320,
"noncommercial_net": 119390,
"commercial_long": 72140,
"commercial_short": 189430,
"open_interest": 578200
}
]
}
In Python, wrap this call in a helper that returns the data list sorted chronologically:
import requests
from datetime import date, timedelta
BASE_URL = "https://fxmacrodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
def fetch_cot(currency: str, lookback_days: int = 365) -> list[dict]:
"""Return COT weekly records for *currency* over the last *lookback_days* days."""
start = (date.today() - timedelta(days=lookback_days)).isoformat()
resp = requests.get(
f"{BASE_URL}/cot/{currency.lower()}",
params={"api_key": API_KEY, "start": start},
timeout=15,
)
resp.raise_for_status()
payload = resp.json()
return sorted(payload["data"], key=lambda r: r["date"])
records = fetch_cot("eur")
print(f"Loaded {len(records)} COT records for EUR")
print("Latest:", records[-1])
Why 12 Months of History?
The filter thresholds in Step 3 are expressed as percentile ranks over the trailing year. One year is long enough to capture a full positioning cycle for most major pairs without including regime changes that are too old to be relevant. You can widen the window to 2–3 years for currencies with slower-moving positioning cycles like JPY or CHF.
Step 2 — Calculate Derived Positioning Metrics
Raw contract counts are hard to compare across currencies and across time. A net long of 80,000 contracts means something very different in EUR futures (large, liquid market) versus CHF (smaller open interest). Two derived metrics resolve this.
2a. Net Position as a Percentage of Open Interest
Dividing non-commercial net position by total open interest produces a normalised ratio between −1 and +1. This makes the metric directly comparable across currencies and over time.
def net_pct_oi(records: list[dict]) -> list[dict]:
"""Add 'net_pct' field = noncommercial_net / open_interest to each record."""
enriched = []
for r in records:
oi = r.get("open_interest") or 1 # guard against zero
enriched.append({**r, "net_pct": r["noncommercial_net"] / oi})
return enriched
records = net_pct_oi(records)
latest = records[-1]
print(f"EUR net % OI: {latest['net_pct']:.3f} ({latest['date']})")
2b. Positioning Percentile Rank
To know whether current positioning is extreme, you need historical context. Ranking the current net_pct within the trailing window converts an absolute number into a percentile (0 = most bearish on record, 100 = most bullish). A percentile above 75 indicates a crowded long; below 25 indicates a crowded short.
def percentile_rank(series: list[float], value: float) -> float:
"""Return the percentile rank of *value* within *series* (0–100)."""
below = sum(1 for x in series if x < value)
return below / len(series) * 100
net_series = [r["net_pct"] for r in records]
current_net = records[-1]["net_pct"]
pct_rank = percentile_rank(net_series, current_net)
print(f"EUR positioning percentile: {pct_rank:.1f}th")
Interpreting Percentile Ranks
- 75th–100th percentile: Non-commercials are crowded long. Favours long entries while trend holds; adds reversal risk if fundamentals shift.
- 25th–75th percentile: Neutral zone. No strong positioning tailwind or headwind — other signals should lead.
- 0th–25th percentile: Non-commercials are crowded short. Favours short entries while trend holds; adds squeeze risk on any bullish surprise.
2c. Positioning Momentum
Trend direction matters as much as the current level. A net long that is growing is a different signal from a net long that has plateaued or started to shrink. Calculate the 4-week change in net_pct to capture momentum:
def positioning_momentum(records: list[dict], periods: int = 4) -> float:
"""Return the change in net_pct over the last *periods* weeks."""
if len(records) < periods + 1:
return 0.0
return records[-1]["net_pct"] - records[-(periods + 1)]["net_pct"]
momentum = positioning_momentum(records)
print(f"EUR 4-week positioning change: {momentum:+.3f}")
Step 3 — Build the Entry Filter
With the three metrics in hand, you can construct a filter function that returns a directional signal for any currency. The logic combines current level, percentile context, and momentum direction into a single gate.
def cot_entry_filter(
currency: str,
lookback_days: int = 365,
long_pct_threshold: float = 55.0,
short_pct_threshold: float = 45.0,
momentum_min: float = 0.005,
) -> dict:
"""
Return a COT positioning signal for *currency*.
Parameters
----------
currency : ISO currency code (AUD, CAD, CHF, EUR, GBP, JPY, NZD, USD)
lookback_days : history window for percentile calculation
long_pct_threshold : minimum percentile to confirm a long bias
short_pct_threshold : maximum percentile to confirm a short bias
momentum_min : minimum absolute 4-week change to confirm momentum
Returns
-------
dict with keys: currency, signal, net_pct, percentile, momentum, date
"""
records = fetch_cot(currency, lookback_days)
records = net_pct_oi(records)
latest = records[-1]
net_series = [r["net_pct"] for r in records]
pct_rank = percentile_rank(net_series, latest["net_pct"])
momentum = positioning_momentum(records)
if pct_rank >= long_pct_threshold and momentum >= momentum_min:
signal = "long"
elif pct_rank <= short_pct_threshold and momentum <= -momentum_min:
signal = "short"
else:
signal = "neutral"
return {
"currency" : currency.upper(),
"signal" : signal,
"net_pct" : round(latest["net_pct"], 4),
"percentile" : round(pct_rank, 1),
"momentum" : round(momentum, 4),
"date" : latest["date"],
}
result = cot_entry_filter("eur")
print(result)
Sample output when EUR non-commercials are running a crowded long and adding to it:
{
"currency" : "EUR",
"signal" : "long",
"net_pct" : 0.231,
"percentile": 82.4,
"momentum" : 0.018,
"date" : "2025-03-25"
}
Step 4 — Apply the Filter to Trade Entries
The filter function returns one of three signals — long, short, or neutral. The intended usage is as a gate ahead of your primary entry signal: only take long setups when the COT filter says long (or neutral if you are more aggressive), and only take short setups when the COT filter says short.
def should_enter_trade(
currency: str,
proposed_direction: str,
allow_neutral: bool = False,
) -> bool:
"""
Return True if COT positioning supports *proposed_direction* for *currency*.
Parameters
----------
currency : ISO currency code
proposed_direction : 'long' or 'short'
allow_neutral : if True, a 'neutral' COT signal does not block entry
"""
cot = cot_entry_filter(currency)
if cot["signal"] == proposed_direction:
return True
if allow_neutral and cot["signal"] == "neutral":
return True
return False
# Example: checking whether to enter a EUR/USD long
currency = "eur" # base currency of the pair
direction = "long"
if should_enter_trade(currency, direction):
print(f"COT confirms {direction} bias for {currency.upper()} — proceed to entry check")
else:
print(f"COT filter blocked {direction} entry for {currency.upper()}")
Implementation Note: COT is a weekly signal
COT data is released every Friday for positions as of the prior Tuesday. That makes it a low-frequency signal — appropriate for filtering weekly or daily bias, not intraday entries. Re-run the filter once per week after Friday's 3:30 pm ET release, cache the result, and use it as a static bias gate for all entries in the following week. Use the COT endpoint docs to verify release timing.
Step 5 — Extend to a Multi-Currency Dashboard
Running the filter across all eight supported currencies at once gives you a weekly positioning dashboard. This is useful for identifying which FX pairs have the clearest speculator-driven tailwinds and which to avoid because positioning is working against your direction.
CURRENCIES = ["aud", "cad", "chf", "eur", "gbp", "jpy", "nzd", "usd"]
def cot_dashboard() -> list[dict]:
"""Return COT positioning signals for all supported currencies."""
results = []
for ccy in CURRENCIES:
try:
result = cot_entry_filter(ccy)
results.append(result)
except Exception as exc:
print(f"Warning: could not load {ccy.upper()} COT data — {exc}")
return results
dashboard = cot_dashboard()
for row in dashboard:
bar = "▲" if row["signal"] == "long" else ("▼" if row["signal"] == "short" else "–")
print(f"{row['currency']:4s} {bar} {row['signal']:8s} pct={row['percentile']:5.1f} mom={row['momentum']:+.3f}")
Sample weekly output:
AUD ▲ long pct= 71.2 mom=+0.012
CAD – neutral pct= 53.8 mom=-0.004
CHF ▲ long pct= 68.4 mom=+0.008
EUR ▲ long pct= 82.4 mom=+0.018
GBP – neutral pct= 48.1 mom=-0.002
JPY ▼ short pct= 19.6 mom=-0.022
NZD ▲ long pct= 63.0 mom=+0.007
USD ▼ short pct= 22.1 mom=-0.015
Reading this snapshot: non-commercials are positioned long EUR, AUD, CHF, and NZD futures; short JPY and USD; and neutral on CAD and GBP. A trader considering EUR/JPY longs would find both legs confirmed by speculator flow. A trader considering USD/CAD longs would face a COT headwind on USD and a neutral backdrop on CAD — a weaker setup from a positioning perspective.
Step 6 — Combine COT with a Macro Confirmation Layer
The most robust setups combine COT positioning with at least one macro fundamental that supports the same directional thesis. Rate differentials are the natural complement: if speculators are long EUR and the ECB-to-Fed rate differential is widening in EUR's favour, the positioning and fundamental case are aligned.
Use the policy rate endpoint to pull the most recent rate for each currency and compute the differential:
def fetch_latest_rate(currency: str) -> float | None:
"""Return the most recent policy rate for *currency* as a float."""
resp = requests.get(
f"{BASE_URL}/announcements/{currency.lower()}/policy_rate",
params={"api_key": API_KEY, "limit": 1},
timeout=15,
)
if resp.status_code != 200:
return None
data = resp.json().get("data", [])
return data[0]["val"] if data else None
def rate_differential(base_ccy: str, quote_ccy: str) -> float | None:
"""Return base_rate − quote_rate, or None if either rate is unavailable."""
base_rate = fetch_latest_rate(base_ccy)
quote_rate = fetch_latest_rate(quote_ccy)
if base_rate is None or quote_rate is None:
return None
return base_rate - quote_rate
def combined_filter(base_ccy: str, quote_ccy: str, direction: str) -> dict:
"""
Return a combined COT + rate-differential signal for a currency pair.
direction : 'long' (buy base) or 'short' (sell base)
"""
cot_base = cot_entry_filter(base_ccy)
cot_quote = cot_entry_filter(quote_ccy)
diff = rate_differential(base_ccy, quote_ccy)
# For a long (buy base): we want long bias on base AND short/neutral on quote
if direction == "long":
cot_ok = cot_base["signal"] == "long" and cot_quote["signal"] != "long"
macro_ok = diff is not None and diff > 0
else:
cot_ok = cot_base["signal"] == "short" and cot_quote["signal"] != "short"
macro_ok = diff is not None and diff < 0
return {
"pair" : f"{base_ccy.upper()}/{quote_ccy.upper()}",
"direction" : direction,
"cot_ok" : cot_ok,
"macro_ok" : macro_ok,
"confirmed" : cot_ok and macro_ok,
"cot_base" : cot_base,
"cot_quote" : cot_quote,
"rate_diff" : round(diff, 4) if diff is not None else None,
}
signal = combined_filter("eur", "jpy", "long")
print("Confirmed:", signal["confirmed"])
print("COT base :", signal["cot_base"]["signal"], "| COT quote:", signal["cot_quote"]["signal"])
print("Rate diff :", signal["rate_diff"])
When COT and Macro Disagree
When positioning is extremely crowded on one side but the macro fundamental is shifting against it (for example, large short JPY futures but the Bank of Japan is beginning to tighten), the regime may be transitioning. These are the setups that produce the fastest and largest moves — often in the direction that forces short-covering or long-liquidation. In such cases the COT filter alone is insufficient; monitor the central bank policy rate history closely for any shift that could trigger a positioning unwind.
Summary
You now have a complete COT-based entry filter built on live FXMacroData API data. The workflow consists of six steps:
- Fetch weekly COT records for the currency or currencies you are trading.
- Calculate net position as a percentage of open interest to normalise across currencies.
- Rank current positioning within the trailing year to identify extreme or neutral conditions.
- Compute 4-week momentum to confirm whether positioning is trending in your favour.
- Gate your trade entries against the filter signal — only proceed when COT alignment matches your direction.
- Optionally combine with a rate-differential check for a two-factor confirmation.
The full multi-currency dashboard gives you a weekly snapshot of where speculator money is positioned, so you enter trades with institutional flow rather than against it. As a next step, consider pairing the COT filter with the inflation or employment endpoints to build a macro-scoring model that weights all three signals together — positioning, rate differentials, and growth/inflation momentum — for a more complete regime picture.