Jaspers Hub

jaspers / cftc

pluginOfficialReviewed★ 0↓ 3

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.

3.4 KB
1import { defineSource } from '@jaspers-ai/sdk'
2import { 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
8const HOST = 'publicreporting.cftc.gov'
9/** Futures only, the legacy report: the one whose columns everyone quotes. */
10const DATASET = '6dca-aqww'
11
12export 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})