CFTC Commitments of Traders: Gets weekly futures positioning by trader type (speculators, hedgers, small traders) from the CFTC's public Commitments of Traders data. No key needed.
| 1 | import { defineSource } from '@jaspers-ai/sdk' |
| 2 | import { z } from 'zod' |
| 3 | |
| 4 | |
| 5 | // The CFTC's Commitments of Traders, from its public reporting site. No key. Who is positioned |
| 6 | // where in futures: what the funds hold against what the commercial hedgers hold. |
| 7 | |
| 8 | const HOST = 'publicreporting.cftc.gov' |
| 9 | /** Futures only, the legacy report: the one whose columns everyone quotes. */ |
| 10 | const DATASET = '6dca-aqww' |
| 11 | |
| 12 | export const positions = defineSource({ |
| 13 | description: |
| 14 | 'Commitments of Traders: weekly futures positioning by trader kind. market matches the contract name, like "GOLD", "E-MINI S&P 500", or "CRUDE OIL". Non-commercial is the speculators, commercial the hedgers.', |
| 15 | hosts: [HOST], |
| 16 | input: z.object({ |
| 17 | market: z.string().describe('Part of the contract name, like GOLD or E-MINI S&P 500.'), |
| 18 | limit: z.number().int().min(1).max(2000).default(104).describe('Weeks to read, newest first. 104 is two years.'), |
| 19 | }), |
| 20 | async run({ market, limit }, ctx) { |
| 21 | const where = `upper(market_and_exchange_names) like upper('%${market.replace(/'/g, "''")}%')` |
| 22 | // Several contracts match a name, and the rows are picked apart below, so ask for enough that |
| 23 | // the one that is kept still has `limit` weeks of its own. |
| 24 | const query = new URLSearchParams({ $where: where, $order: 'report_date_as_yyyy_mm_dd DESC', $limit: String(Math.min(limit * 6, 10000)) }) |
| 25 | const response = await ctx.fetch(`https://${HOST}/resource/${DATASET}.json?${query.toString()}`) |
| 26 | if (!response.ok) throw new Error(`The CFTC answered ${response.status}.`) |
| 27 | const found = (await response.json()) as Record<string, string>[] |
| 28 | if (found.length === 0) throw new Error(`No contract matches "${market}". Try a shorter piece of its name, like GOLD.`) |
| 29 | // A name matches several contracts (GOLD also finds MICRO GOLD and PAX GOLD), and mixing them |
| 30 | // in one series is a chart of nothing. The largest by open interest is the one meant; the rest |
| 31 | // come back named, so a caller who wanted one of those can ask for it exactly. |
| 32 | const byMarket = new Map<string, Record<string, string>[]>() |
| 33 | for (const row of found) { |
| 34 | const name = row['market_and_exchange_names'] ?? '' |
| 35 | byMarket.set(name, [...(byMarket.get(name) ?? []), row]) |
| 36 | } |
| 37 | const biggest = [...byMarket.entries()].sort( |
| 38 | (a, b) => Number(b[1][0]?.['open_interest_all'] ?? 0) - Number(a[1][0]?.['open_interest_all'] ?? 0), |
| 39 | ) |
| 40 | const rows = (biggest[0]?.[1] ?? []).slice(0, limit) |
| 41 | .map((row) => { |
| 42 | const number = (key: string): number | null => (row[key] === undefined ? null : Number(row[key])) |
| 43 | const long = number('noncomm_positions_long_all') |
| 44 | const short = number('noncomm_positions_short_all') |
| 45 | return { |
| 46 | market: row['market_and_exchange_names'], |
| 47 | date: row['report_date_as_yyyy_mm_dd']?.slice(0, 10), |
| 48 | openInterest: number('open_interest_all'), |
| 49 | fundsLong: long, |
| 50 | fundsShort: short, |
| 51 | // The number the report is read for: what the speculators are betting, on balance. |
| 52 | fundsNet: long !== null && short !== null ? long - short : null, |
| 53 | hedgersLong: number('comm_positions_long_all'), |
| 54 | hedgersShort: number('comm_positions_short_all'), |
| 55 | smallLong: number('nonrept_positions_long_all'), |
| 56 | smallShort: number('nonrept_positions_short_all'), |
| 57 | } |
| 58 | }) |
| 59 | .sort((a, b) => String(a.date).localeCompare(String(b.date))) |
| 60 | return { market: rows[0]?.market, alsoMatched: biggest.slice(1).map(([name]) => name), rows } |
| 61 | }, |
| 62 | }) |