Files
website/scripts/stats/ustreasury.ts
2026-10-25 21:05:51 +00:00

308 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// A11 US Treasury Fiscal Data: Monthly Treasury Statement receipts (table 4) and outlays
// (table 5), and the daily Debt to the Penny. Normalised: monthly and fiscal-year receipts by
// type (customs duties = tariffs), outlays (total and per agency), deficit, and debt.
import {
getJson,
insertChunks,
log,
run,
sql,
writeSeries,
type ObsRow,
type SeriesRow
} from './common';
const BASE = 'https://api.fiscaldata.treasury.gov/services/api/fiscal_service';
type Page = { data: Record<string, string>[]; meta: { 'total-pages': number } };
async function pullAll(endpoint: string, filter: string | null) {
const out: Record<string, string>[] = [];
for (let page = 1; ; page++) {
const q = new URLSearchParams({
'page[size]': '10000',
'page[number]': String(page),
sort: 'record_date'
});
if (filter) q.set('filter', filter);
const body = await getJson<Page>(`${BASE}/${endpoint}?${q}`);
out.push(...body.data);
if (page >= (body.meta['total-pages'] ?? 1)) break;
}
return out;
}
const num = (v: string | undefined) => (v == null || v === 'null' || v === '' ? null : Number(v));
const int = (v: string | undefined) => (num(v) == null ? null : Math.trunc(Number(v)));
/** Receipt lines kept as series (MTS table 4 descriptions, normalised to lower case). */
const RECEIPTS: [string, RegExp, string][] = [
['individual', /^total -- individual income taxes$/, 'Individual income taxes'],
['corporate', /^(total -- )?corporation income taxes$/, 'Corporation income taxes'],
[
'social',
/^total -- social insurance and retirement receipts$/,
'Social insurance and retirement receipts'
],
['excise', /^total -- excise taxes$/, 'Excise taxes'],
['estate', /^(total -- )?estate and gift taxes$/, 'Estate and gift taxes'],
['customs', /^(total -- )?customs duties$/, 'Customs duties (tariffs)'],
['misc', /^total -- miscellaneous receipts$/, 'Miscellaneous receipts'],
['total', /^total -- receipts$/, 'Total receipts']
];
const clean = (s: string) =>
s
.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/\s*--\s*/g, ' -- ')
.trim();
const slug = (s: string) =>
s
.toLowerCase()
.replace(/&/g, 'and')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
/** US fiscal year of a record date (FY2026 = Oct 2025 – Sep 2026). */
const fiscalYear = (d: string) => Number(d.slice(0, 4)) + (Number(d.slice(5, 7)) >= 10 ? 1 : 0);
export async function ustreasury(opts: { backfill?: boolean; job: string }) {
await run('ustreasury', opts.job, async (note) => {
const since = new Date(Date.now() - 400 * 86_400_000).toISOString().slice(0, 10);
const filter = opts.backfill ? null : `record_date:gte:${since}`;
let total = 0;
const mts = async (
table: 'mts_table_4' | 'mts_table_5',
target: string,
kind: 'rcpt' | 'outly'
) => {
const rows = (await pullAll(`v1/accounting/mts/${table}`, filter)).map((r) => ({
record_date: r.record_date,
classification_id: r.classification_id,
parent_id: r.parent_id === 'null' ? null : r.parent_id,
description: r.classification_desc,
level: int(r.sequence_level_nbr),
print_order: int(r.print_order_nbr),
net_month: num(r[`current_month_net_${kind}_amt`]),
net_fytd: num(r[`current_fytd_net_${kind}_amt`]),
gross_month: num(r[`current_month_gross_${kind}_amt`])
}));
await sql.begin(async (tx) => {
await insertChunks(
tx,
target,
rows,
`on conflict (record_date, classification_id) do update set
parent_id = excluded.parent_id, description = excluded.description, level = excluded.level,
print_order = excluded.print_order, net_month = excluded.net_month, net_fytd = excluded.net_fytd,
gross_month = excluded.gross_month`
);
});
log(`ustreasury ${table}: ${rows.length} rows`);
return rows.length;
};
for (const [table, target, kind] of [
['mts_table_4', 'ust_mts_receipts', 'rcpt'],
['mts_table_5', 'ust_mts_outlays', 'outly']
] as const) {
try {
total += await mts(table, target, kind);
} catch (error) {
note(`${table}: ${error instanceof Error ? error.message : String(error)}`);
}
}
try {
const rows = (await pullAll('v2/accounting/od/debt_to_penny', filter)).map((r) => ({
record_date: r.record_date,
debt_held_public: num(r.debt_held_public_amt),
intragov_holdings: num(r.intragov_hold_amt),
total_debt: num(r.tot_pub_debt_out_amt)
}));
await sql.begin(async (tx) => {
await insertChunks(
tx,
'ust_debt_daily',
rows,
`on conflict (record_date) do update set
debt_held_public = excluded.debt_held_public, intragov_holdings = excluded.intragov_holdings,
total_debt = excluded.total_debt`
);
});
log(`ustreasury debt_to_penny: ${rows.length} rows`);
total += rows.length;
} catch (error) {
note(`debt_to_penny: ${error instanceof Error ? error.message : String(error)}`);
}
await normaliseUst();
return total;
});
}
export async function normaliseUst() {
const meta = { url: 'https://fiscaldata.treasury.gov/datasets/monthly-treasury-statement/' };
const series: SeriesRow[] = [];
const obs: ObsRow[] = [];
const add = (
id: string,
title: string,
unit: string,
freq: string,
rows: [string, number | null][],
m = meta
) => {
series.push({
series_id: id,
source_id: 'ustreasury',
source_code: id.slice(4),
title,
unit,
freq,
region_code: 'USA',
theme: 'budget',
meta: m
});
for (const [period, value] of rows)
if (value != null) obs.push({ series_id: id, period, value, status: null });
};
const receipts = await sql<
{
record_date: string;
description: string;
net_month: number | null;
net_fytd: number | null;
}[]
>`
select record_date::text, description, net_month, net_fytd from stats.ust_mts_receipts order by record_date`;
for (const [key, re, title] of RECEIPTS) {
const rows = receipts.filter((r) => re.test(clean(r.description)));
// Some months repeat a line (sub-totals); keep the first per date.
const month = new Map<string, (typeof rows)[number]>();
for (const r of rows) if (!month.has(r.record_date)) month.set(r.record_date, r);
const list = [...month.values()];
add(
`ust:receipts:${key}:M`,
title,
'USD bn',
'M',
list.map((r) => [
`${r.record_date.slice(0, 7)}-01`,
r.net_month == null ? null : r.net_month / 1e9
])
);
add(
`ust:receipts:${key}:FY`,
`${title}, fiscal year`,
'USD bn',
'A',
list
.filter((r) => r.record_date.slice(5, 7) === '09')
.map((r) => [
`${fiscalYear(r.record_date)}-01-01`,
r.net_fytd == null ? null : r.net_fytd / 1e9
])
);
}
const outlays = await sql<
{
record_date: string;
description: string;
level: number | null;
net_month: number | null;
net_fytd: number | null;
}[]
>`
select record_date::text, description, level, net_month, net_fytd from stats.ust_mts_outlays order by record_date, print_order`;
const pick = (re: RegExp) => {
const m = new Map<string, (typeof outlays)[number]>();
for (const r of outlays)
if (re.test(clean(r.description)) && !m.has(r.record_date)) m.set(r.record_date, r);
return [...m.values()];
};
const totalOut = pick(/^total outlays$/);
add(
'ust:outlays:total:M',
'Total outlays',
'USD bn',
'M',
totalOut.map((r) => [
`${r.record_date.slice(0, 7)}-01`,
r.net_month == null ? null : r.net_month / 1e9
])
);
add(
'ust:outlays:total:FY',
'Total outlays, fiscal year',
'USD bn',
'A',
totalOut
.filter((r) => r.record_date.slice(5, 7) === '09')
.map((r) => [
`${fiscalYear(r.record_date)}-01-01`,
r.net_fytd == null ? null : r.net_fytd / 1e9
])
);
const deficit = pick(/^total surplus \(\+\) or deficit \(-\)$/);
add(
'ust:deficit:M',
'Surplus (+) or deficit (−)',
'USD bn',
'M',
deficit.map((r) => [
`${r.record_date.slice(0, 7)}-01`,
r.net_month == null ? null : r.net_month / 1e9
])
);
add(
'ust:deficit:FY',
'Surplus (+) or deficit (−), fiscal year',
'USD bn',
'A',
deficit
.filter((r) => r.record_date.slice(5, 7) === '09')
.map((r) => [
`${fiscalYear(r.record_date)}-01-01`,
r.net_fytd == null ? null : r.net_fytd / 1e9
])
);
// Agency totals: "Total--Department of Defense--Military Programs" etc.
const agencies = new Map<string, Map<string, number | null>>();
for (const r of outlays) {
const d = r.description.replace(/\s+/g, ' ').trim();
const m = /^Total\s*--\s*(.+)$/.exec(d);
if (!m || r.level !== 2 || r.record_date.slice(5, 7) !== '09') continue;
const name = m[1].trim();
if (!agencies.has(name)) agencies.set(name, new Map());
const fy = `${fiscalYear(r.record_date)}-01-01`;
if (!agencies.get(name)!.has(fy))
agencies.get(name)!.set(fy, r.net_fytd == null ? null : r.net_fytd / 1e9);
}
for (const [name, years] of agencies)
add(
`ust:outlays:${slug(name)}:FY`,
`Outlays: ${name}, fiscal year`,
'USD bn',
'A',
[...years.entries()],
{ ...meta, agency: name }
);
const debt = await sql<
{ d: string; total_debt: number | null; debt_held_public: number | null }[]
>`
select record_date::text as d, total_debt, debt_held_public from stats.ust_debt_daily order by 1`;
const dm = { url: 'https://fiscaldata.treasury.gov/datasets/debt-to-the-penny/' };
add(
'ust:debt:D',
'Total public debt outstanding',
'USD tn',
'D',
debt.map((r) => [r.d, r.total_debt == null ? null : r.total_debt / 1e12]),
dm
);
add(
'ust:debt:public:D',
'Debt held by the public',
'USD tn',
'D',
debt.map((r) => [r.d, r.debt_held_public == null ? null : r.debt_held_public / 1e12]),
dm
);
return writeSeries(series, obs);
}