stats step 2: World Bank, IMF WEO, OWID, Energy-Charts, Rijksfinanciën, US Treasury, USAspending collectors + tables, timers
All checks were successful
Deploy production / deploy (push) Successful in 1m10s

This commit is contained in:
2026-10-25 21:05:51 +00:00
parent 7e905b1651
commit 8958d3ff8f
21 changed files with 34655 additions and 3 deletions

View File

@@ -177,7 +177,7 @@ try {
throw new Error(`groceries schema grants are wrong: ${JSON.stringify(groceries)}`);
// Public /stats section: open data, so the app role (and the host-side stats collector, which
// uses it) reads and writes, and personal_ro may read. Bump the count with every new stats table.
const STATS_TABLES = 12;
const STATS_TABLES = 23;
const [stats] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,

View File

@@ -6,12 +6,22 @@
// bun scripts/stats-fetch.ts eurostat [--only=nrg_pc_204]
// bun scripts/stats-fetch.ts fred [--only=DCOILBRENTEU]
// bun scripts/stats-fetch.ts bis
// bun scripts/stats-fetch.ts worldbank|imf|owid [--only=…]
// bun scripts/stats-fetch.ts energycharts|rijksfinancien|ustreasury [--backfill]
// bun scripts/stats-fetch.ts usaspending
// bun scripts/stats-fetch.ts normalise-cbs rebuild CBS chart series from the raw rows
import { ensureMeta, sql } from './stats/common';
import { cbs, normaliseCbs } from './stats/cbs';
import { eurostat } from './stats/eurostat';
import { fred } from './stats/fred';
import { bis } from './stats/bis';
import { worldbank } from './stats/worldbank';
import { imf } from './stats/imf';
import { owid } from './stats/owid';
import { energycharts } from './stats/energycharts';
import { rijksfinancien } from './stats/rijksfinancien';
import { ustreasury } from './stats/ustreasury';
import { usaspending } from './stats/usaspending';
const [source, ...args] = process.argv.slice(2);
const flag = (name: string) => args.includes(`--${name}`);
@@ -36,8 +46,31 @@ try {
case 'bis':
await bis({ job });
break;
case 'worldbank':
await worldbank({ only: value('only'), job });
break;
case 'imf':
await imf({ only: value('only'), job });
break;
case 'owid':
await owid({ only: value('only'), job });
break;
case 'energycharts':
await energycharts({ backfill: flag('backfill'), job });
break;
case 'rijksfinancien':
await rijksfinancien({ backfill: flag('backfill'), job });
break;
case 'ustreasury':
await ustreasury({ backfill: flag('backfill'), job });
break;
case 'usaspending':
await usaspending({ job });
break;
default:
throw new Error(`Unknown source "${source}" (cbs, eurostat, fred, bis, normalise-cbs)`);
throw new Error(
`Unknown source "${source}" (cbs, eurostat, fred, bis, worldbank, imf, owid, energycharts, rijksfinancien, ustreasury, usaspending, normalise-cbs)`
);
}
} finally {
await sql.end({ timeout: 5 });

View File

@@ -23,7 +23,15 @@ const hostGapMs: Record<string, number> = {
'sdmx.oecd.org': 3100,
'fred.stlouisfed.org': 1000,
'ec.europa.eu': 500,
'stats.bis.org': 1000
'stats.bis.org': 1000,
'api.worldbank.org': 1000,
'www.imf.org': 1500,
'ourworldindata.org': 2000,
'owid-public.owid.io': 2000,
'api.energy-charts.info': 2000,
'www.rijksfinancien.nl': 1000,
'api.fiscaldata.treasury.gov': 1000,
'api.usaspending.gov': 500
};
const lastHit = new Map<string, number>();
@@ -127,6 +135,64 @@ export const SOURCES = [
licence: 'Free reuse with attribution',
key_required: false,
attribution: 'Source: BIS, central bank policy rates (WS_CBPOL)'
},
{
source_id: 'worldbank',
name: 'World Bank World Development Indicators',
base_url: 'https://api.worldbank.org/v2',
licence: 'CC BY 4.0',
key_required: false,
attribution: 'Source: World Bank, World Development Indicators, CC BY 4.0'
},
{
source_id: 'imf',
name: 'IMF World Economic Outlook (DataMapper)',
base_url: 'https://www.imf.org/external/datamapper/api/v1',
licence: 'IMF copyright, free reuse with attribution',
key_required: false,
attribution: 'Source: IMF, World Economic Outlook (DataMapper)'
},
{
source_id: 'owid',
name: 'Our World in Data',
base_url: 'https://ourworldindata.org',
licence: 'CC BY 4.0',
key_required: false,
attribution:
'Source: Our World in Data (Maddison Project Database 2023; Energy Institute, Ember), CC BY 4.0'
},
{
source_id: 'energycharts',
name: 'Energy-Charts (Fraunhofer ISE)',
base_url: 'https://api.energy-charts.info',
licence: 'CC BY 4.0',
key_required: false,
attribution: 'Source: Energy-Charts.info (Fraunhofer ISE; ENTSO-E, SMARD), CC BY 4.0'
},
{
source_id: 'rijksfinancien',
name: 'Rijksfinanciën (Ministerie van Financiën)',
base_url: 'https://www.rijksfinancien.nl/open-data/api',
licence: 'CC0',
key_required: false,
attribution: 'Source: Rijksfinanciën open data, Ministerie van Financiën (CC0)'
},
{
source_id: 'ustreasury',
name: 'US Treasury Fiscal Data',
base_url: 'https://api.fiscaldata.treasury.gov/services/api/fiscal_service',
licence: 'Public domain (US government work)',
key_required: false,
attribution:
'Source: U.S. Treasury, Fiscal Data (Monthly Treasury Statement, Debt to the Penny)'
},
{
source_id: 'usaspending',
name: 'USAspending.gov',
base_url: 'https://api.usaspending.gov/api/v2',
licence: 'Public domain (US government work)',
key_required: false,
attribution: 'Source: USAspending.gov (U.S. Department of the Treasury)'
}
] as const;
@@ -152,6 +218,8 @@ export const REGIONS: { region_code: string; name: string; kind: string; parent:
{ region_code: 'GBR', name: 'United Kingdom', kind: 'country', parent: 'WLD' },
{ region_code: 'JPN', name: 'Japan', kind: 'country', parent: 'WLD' },
{ region_code: 'IND', name: 'India', kind: 'country', parent: 'WLD' },
{ region_code: 'BRA', name: 'Brazil', kind: 'country', parent: 'WLD' },
{ region_code: 'RUS', name: 'Russia', kind: 'country', parent: 'WLD' },
{ region_code: 'CR28', name: 'Oost-Zuid-Holland (COROP)', kind: 'corop', parent: 'NLD' },
{ region_code: 'GM0513', name: 'Gouda', kind: 'municipality', parent: 'CR28' },
{ region_code: 'GM0627', name: 'Waddinxveen', kind: 'municipality', parent: 'CR28' },
@@ -162,6 +230,46 @@ export const REGIONS: { region_code: string; name: string; kind: string; parent:
{ region_code: 'GM0632', name: 'Woerden', kind: 'municipality', parent: 'NLD' }
];
/** Countries compared on /stats/countries (ISO3), in display order. */
export const COMPARE_ISO3 = [
'NLD',
'USA',
'KOR',
'CHN',
'CUW',
'DEU',
'FRA',
'BEL',
'GBR',
'JPN',
'IND',
'BRA',
'RUS'
];
/**
* Batch insert inside an open transaction: one jsonb parameter per chunk, so big tables load fast
* without hitting the bind-parameter limit. Row keys are column names (snake_case).
*/
export async function insertChunks(
tx: postgres.TransactionSql,
table: string,
rows: Record<string, unknown>[],
conflict = '',
size = 20000
) {
if (!rows.length) return;
const cols = Object.keys(rows[0])
.map((k) => `"${k}"`)
.join(', ');
for (let i = 0; i < rows.length; i += size) {
await tx.unsafe(
`insert into stats.${table} (${cols}) select ${cols} from jsonb_populate_recordset(null::stats.${table}, $1::jsonb) ${conflict}`,
[JSON.stringify(rows.slice(i, i + size))] as never
);
}
}
/** Gouda and its house-hunting ring (CBS gemeente codes). */
export const GOUDA_RING = ['GM0513', 'GM0627', 'GM1901', 'GM1892', 'GM1931', 'GM0484', 'GM0632'];

View File

@@ -0,0 +1,120 @@
// A10 Energy-Charts (Fraunhofer ISE): NL day-ahead prices and NL cross-border physical flows.
// Raw rows keep the API resolution (hourly until Oct 2025, 15-minute after); the normalised
// layer holds daily / monthly averages (prices) and monthly net TWh per neighbour (flows).
import { getJson, insertChunks, log, run, sql } from './common';
const BASE = 'https://api.energy-charts.info';
const BACKFILL_FROM = '2015-01-01';
const ymd = (d: Date) => d.toISOString().slice(0, 10);
function monthChunks(from: string, to: Date) {
const out: [string, string][] = [];
const d = new Date(`${from}T00:00:00Z`);
while (d < to) {
const start = ymd(d);
d.setUTCMonth(d.getUTCMonth() + 1);
const end = new Date(d.getTime() - 86_400_000);
out.push([start, ymd(end < to ? end : to)]);
}
return out;
}
type PriceBody = { unix_seconds?: number[]; price?: (number | null)[] };
type CbetBody = {
unix_seconds?: number[];
countries?: { name: string; data: (number | null)[] }[];
};
async function pullRange(start: string, end: string) {
let rows = 0;
const price = await getJson<PriceBody>(`${BASE}/price?bzn=NL&start=${start}&end=${end}`);
const p = (price.unix_seconds ?? []).map((t, i) => ({
bzn: 'NL',
ts: new Date(t * 1000).toISOString(),
eur_mwh: price.price?.[i] ?? null
}));
const cbet = await getJson<CbetBody>(`${BASE}/cbet?country=nl&start=${start}&end=${end}`);
const c = (cbet.countries ?? []).flatMap((n) =>
(cbet.unix_seconds ?? []).map((t, i) => ({
country: 'nl',
neighbour: n.name,
ts: new Date(t * 1000).toISOString(),
gw: n.data[i] ?? null
}))
);
await sql.begin(async (tx) => {
await insertChunks(
tx,
'ec_dayahead_price',
p,
'on conflict (bzn, ts) do update set eur_mwh = excluded.eur_mwh'
);
await insertChunks(
tx,
'ec_crossborder',
c,
'on conflict (country, neighbour, ts) do update set gw = excluded.gw'
);
});
rows += p.length + c.length;
return rows;
}
/** Rebuild the chart series from the raw tables (cheap: a few hundred thousand rows). */
export async function normaliseEnergyCharts() {
const source = { url: 'https://www.energy-charts.info/charts/price_spot_market/chart.htm?c=NL' };
await sql.begin(async (tx) => {
const series = [
['ec:price:NL:D', 'Day-ahead electricity price, daily average', 'EUR/MWh', 'D'],
['ec:price:NL:M', 'Day-ahead electricity price, monthly average', 'EUR/MWh', 'M']
];
for (const [id, title, unit, freq] of series)
await tx`insert into stats.series (series_id, source_id, source_code, title, unit, freq, region_code, theme, meta)
values (${id}, 'energycharts', 'price/NL', ${title}, ${unit}, ${freq}, 'NLD', 'energy', ${tx.json(source)})
on conflict (series_id) do update set title = excluded.title, unit = excluded.unit, meta = excluded.meta`;
await tx`insert into stats.series (series_id, source_id, source_code, title, unit, freq, region_code, theme, meta)
select 'ec:cbet:nl:' || lower(replace(neighbour, ' ', '-')) || ':M', 'energycharts', 'cbet/nl/' || neighbour,
'Net physical electricity import from ' || neighbour, 'TWh', 'M', 'NLD', 'energy',
${tx.json({ url: 'https://www.energy-charts.info/charts/import_export/chart.htm?c=NL', sign: 'positive = import into NL' })}
from (select distinct neighbour from stats.ec_crossborder where country = 'nl') n
on conflict (series_id) do update set title = excluded.title, meta = excluded.meta`;
await tx`delete from stats.obs where series_id like 'ec:%'`;
await tx`insert into stats.obs (series_id, period, value)
select 'ec:price:NL:D', (ts at time zone 'Europe/Amsterdam')::date, avg(eur_mwh)
from stats.ec_dayahead_price where bzn = 'NL' and eur_mwh is not null group by 2`;
await tx`insert into stats.obs (series_id, period, value)
select 'ec:price:NL:M', date_trunc('month', ts at time zone 'Europe/Amsterdam')::date, avg(eur_mwh)
from stats.ec_dayahead_price where bzn = 'NL' and eur_mwh is not null group by 2`;
// Average GW over the month × hours in the month / 1000 = TWh (robust to the resolution change).
await tx`insert into stats.obs (series_id, period, value)
select 'ec:cbet:nl:' || lower(replace(neighbour, ' ', '-')) || ':M', m,
avg(gw) * extract(epoch from (m + interval '1 month') - m) / 3600 / 1000
from (select neighbour, gw, date_trunc('month', ts at time zone 'Europe/Amsterdam')::date as m
from stats.ec_crossborder where country = 'nl' and gw is not null) x
group by neighbour, m`;
});
const [{ n }] = await sql`select count(*)::int as n from stats.obs where series_id like 'ec:%'`;
return n as number;
}
export async function energycharts(opts: { backfill?: boolean; job: string }) {
await run('energycharts', opts.job, async (note) => {
const now = new Date();
const tomorrow = new Date(now.getTime() + 86_400_000);
const chunks = opts.backfill
? monthChunks(BACKFILL_FROM, tomorrow)
: [[ymd(new Date(now.getTime() - 3 * 86_400_000)), ymd(tomorrow)] as [string, string]];
let rows = 0;
for (const [start, end] of chunks) {
try {
rows += await pullRange(start, end);
if (opts.backfill) log(`energycharts ${start}..${end}: ${rows} rows so far`);
} catch (error) {
note(`${start}..${end}: ${error instanceof Error ? error.message : String(error)}`);
}
}
const obs = await normaliseEnergyCharts();
log(`energycharts: ${rows} raw rows, ${obs} normalised obs`);
return rows;
});
}

128
scripts/stats/imf.ts Normal file
View File

@@ -0,0 +1,128 @@
// A4 IMF World Economic Outlook via DataMapper. The country filter in the path is ignored, so
// each call returns every country; keep the compared ones. Each WEO round is kept as a vintage
// (April / October) so forecasts can later be compared with outcomes.
import {
COMPARE_ISO3,
getJson,
insertChunks,
log,
run,
sql,
writeSeries,
type ObsRow,
type SeriesRow
} from './common';
export const IMF_INDICATORS: { id: string; title: string; unit: string; theme: string }[] = [
{ id: 'NGDP_RPCH', title: 'Real GDP growth', unit: '%', theme: 'countries' },
{ id: 'NGDPDPC', title: 'GDP per capita', unit: 'current USD', theme: 'countries' },
{
id: 'PPPPC',
title: 'GDP per capita, PPP',
unit: 'current international $',
theme: 'countries'
},
{ id: 'PCPIPCH', title: 'Inflation (average consumer prices)', unit: '%', theme: 'countries' },
{ id: 'LUR', title: 'Unemployment rate', unit: '%', theme: 'countries' },
{ id: 'GGXWDG_NGDP', title: 'General government gross debt', unit: '% of GDP', theme: 'budget' },
{
id: 'GGXCNL_NGDP',
title: 'General government net lending/borrowing',
unit: '% of GDP',
theme: 'budget'
},
{ id: 'GGR_NGDP', title: 'General government revenue', unit: '% of GDP', theme: 'budget' },
{
id: 'GGX_NGDP',
title: 'General government total expenditure',
unit: '% of GDP',
theme: 'budget'
}
];
// IMF aggregate codes kept besides the countries.
const AGGREGATES: Record<string, string> = { EU: 'EU27', WEOWORLD: 'WLD' };
/** WEO round in force on a date: October and April releases. */
export function currentVintage(now = new Date()) {
const y = now.getUTCFullYear();
const m = now.getUTCMonth() + 1;
return m >= 10 ? `${y}-10` : m >= 4 ? `${y}-04` : `${y - 1}-10`;
}
export async function imf(opts: { only?: string; job: string }) {
const vintage = currentVintage();
const vintageYear = Number(vintage.slice(0, 4));
await run('imf', opts.job, async (note) => {
let total = 0;
for (const ind of IMF_INDICATORS) {
if (opts.only && !opts.only.split(',').includes(ind.id)) continue;
try {
const body = await getJson<{
values?: Record<string, Record<string, Record<string, number>>>;
}>(`https://www.imf.org/external/datamapper/api/v1/${ind.id}`);
const byCountry = body.values?.[ind.id] ?? {};
const rows: {
vintage: string;
indicator: string;
country_iso3: string;
year: number;
value: number;
is_forecast: boolean;
}[] = [];
for (const [code, years] of Object.entries(byCountry)) {
const region = COMPARE_ISO3.includes(code) ? code : AGGREGATES[code];
if (!region) continue;
for (const [y, v] of Object.entries(years))
if (v != null)
rows.push({
vintage,
indicator: ind.id,
country_iso3: region,
year: Number(y),
value: v,
is_forecast: Number(y) >= vintageYear
});
}
if (!rows.length) throw new Error('no rows');
await sql.begin(async (tx) => {
await tx`delete from stats.imf_weo where vintage = ${vintage} and indicator = ${ind.id}`;
await insertChunks(tx, 'imf_weo', rows);
});
const series: SeriesRow[] = [];
const obs: ObsRow[] = [];
for (const region of new Set(rows.map((r) => r.country_iso3))) {
const id = `imf:${ind.id}:${region}`;
series.push({
series_id: id,
source_id: 'imf',
source_code: `${ind.id}/${region}`,
title: ind.title,
unit: ind.unit,
freq: 'A',
region_code: region,
theme: ind.theme,
meta: {
vintage,
forecastFrom: vintageYear,
url: `https://www.imf.org/external/datamapper/${ind.id}`
}
});
for (const r of rows)
if (r.country_iso3 === region)
obs.push({
series_id: id,
period: `${r.year}-01-01`,
value: r.value,
status: r.is_forecast ? 'forecast' : null
});
}
await writeSeries(series, obs);
log(`imf ${ind.id}: ${rows.length} rows (vintage ${vintage})`);
total += rows.length;
} catch (error) {
note(`${ind.id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return total;
});
}

218
scripts/stats/owid.ts Normal file
View File

@@ -0,0 +1,218 @@
// A9 Our World in Data: Maddison GDP per capita (NL back to 1348) and the OWID energy dataset
// (energy mix per country). Both are full CSV downloads, replaced on every run.
import {
COMPARE_ISO3,
getText,
insertChunks,
log,
run,
sql,
writeSeries,
type ObsRow,
type SeriesRow
} from './common';
const MADDISON_URL =
'https://ourworldindata.org/grapher/gdp-per-capita-maddison-project-database.csv?csvType=full&useColumnShortNames=true';
const ENERGY_URL = 'https://owid-public.owid.io/data/energy/owid-energy-data.csv';
/** OWID energy columns kept (raw) and the ones normalised into series for compared countries. */
export const ENERGY_COLUMNS: Record<string, { title: string; unit: string; series?: boolean }> = {
population: { title: 'Population', unit: 'people' },
energy_per_capita: { title: 'Primary energy per person', unit: 'kWh', series: true },
primary_energy_consumption: { title: 'Primary energy consumption', unit: 'TWh', series: true },
per_capita_electricity: { title: 'Electricity generation per person', unit: 'kWh', series: true },
electricity_generation: { title: 'Electricity generation', unit: 'TWh' },
carbon_intensity_elec: {
title: 'Carbon intensity of electricity',
unit: 'gCO₂/kWh',
series: true
},
fossil_share_energy: { title: 'Fossil fuels, share of primary energy', unit: '%', series: true },
renewables_share_energy: {
title: 'Renewables, share of primary energy',
unit: '%',
series: true
},
low_carbon_share_elec: { title: 'Low-carbon share of electricity', unit: '%', series: true },
coal_share_elec: { title: 'Coal, share of electricity', unit: '%', series: true },
gas_share_elec: { title: 'Gas, share of electricity', unit: '%', series: true },
oil_share_elec: { title: 'Oil, share of electricity', unit: '%', series: true },
nuclear_share_elec: { title: 'Nuclear, share of electricity', unit: '%', series: true },
hydro_share_elec: { title: 'Hydro, share of electricity', unit: '%', series: true },
wind_share_elec: { title: 'Wind, share of electricity', unit: '%', series: true },
solar_share_elec: { title: 'Solar, share of electricity', unit: '%', series: true },
biofuel_share_elec: { title: 'Bioenergy, share of electricity', unit: '%', series: true },
other_renewables_share_elec_exc_biofuel: {
title: 'Other renewables, share of electricity',
unit: '%'
},
coal_consumption: { title: 'Coal consumption', unit: 'TWh' },
oil_consumption: { title: 'Oil consumption', unit: 'TWh' },
gas_consumption: { title: 'Gas consumption', unit: 'TWh' },
nuclear_consumption: { title: 'Nuclear consumption', unit: 'TWh' },
renewables_consumption: { title: 'Renewables consumption', unit: 'TWh' },
net_elec_imports_share_demand: { title: 'Net electricity imports, share of demand', unit: '%' }
};
/** Minimal CSV parser (quoted fields, commas inside quotes). */
export function parseCsv(text: string): string[][] {
const out: string[][] = [];
let row: string[] = [];
let field = '';
let quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
field += '"';
i++;
} else if (ch === '"') quoted = false;
else field += ch;
} else if (ch === '"') quoted = true;
else if (ch === ',') {
row.push(field);
field = '';
} else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++;
row.push(field);
out.push(row);
row = [];
field = '';
} else field += ch;
}
if (field || row.length) {
row.push(field);
out.push(row);
}
return out;
}
const regionOf = (iso3: string, entity: string) =>
COMPARE_ISO3.includes(iso3)
? iso3
: entity === 'World'
? 'WLD'
: entity === 'European Union (27)'
? 'EU27'
: null;
export async function owid(opts: { only?: string; job: string }) {
await run('owid', opts.job, async (note) => {
let total = 0;
if (!opts.only || opts.only.includes('maddison')) {
try {
const [header, ...lines] = parseCsv(await getText(MADDISON_URL));
const col = (n: string) => header.indexOf(n);
const [e, c, y, g] = [col('entity'), col('code'), col('year'), col('gdp_per_capita')];
const rows = lines
.filter((l) => l.length > g && l[g] !== '')
.map((l) => ({
entity: l[e],
iso3: l[c] || null,
year: Number(l[y]),
gdp_pc: Number(l[g])
}));
if (rows.length < 1000) throw new Error(`only ${rows.length} rows`);
await sql.begin(async (tx) => {
await tx`delete from stats.owid_maddison`;
await insertChunks(tx, 'owid_maddison', rows);
});
const series: SeriesRow[] = [];
const obs: ObsRow[] = [];
const seen = new Set<string>();
for (const r of rows) {
const region = regionOf(r.iso3 ?? '', r.entity);
if (!region) continue;
const id = `owid:maddison:${region}`;
if (!seen.has(id)) {
seen.add(id);
series.push({
series_id: id,
source_id: 'owid',
source_code: `maddison/${r.entity}`,
title: 'GDP per capita (Maddison)',
unit: 'international-$ at 2011 prices',
freq: 'A',
region_code: region,
theme: 'countries',
meta: {
url: 'https://ourworldindata.org/grapher/gdp-per-capita-maddison-project-database'
}
});
}
obs.push({
series_id: id,
period: `${String(r.year).padStart(4, '0')}-01-01`,
value: r.gdp_pc,
status: null
});
}
await writeSeries(series, obs);
log(`owid maddison: ${rows.length} rows`);
total += rows.length;
} catch (error) {
note(`maddison: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (!opts.only || opts.only.includes('energy')) {
try {
const [header, ...lines] = parseCsv(await getText(ENERGY_URL));
const idx = Object.fromEntries(header.map((h, i) => [h, i]));
const keep = Object.keys(ENERGY_COLUMNS).filter((k) => k in idx);
const rows = lines
.filter((l) => l.length >= header.length - 1 && l[idx.country])
.map((l) => {
const values: Record<string, number> = {};
for (const k of keep)
if (l[idx[k]] !== '' && l[idx[k]] != null) values[k] = Number(l[idx[k]]);
return {
country: l[idx.country],
iso3: l[idx.iso_code] || null,
year: Number(l[idx.year]),
values
};
})
.filter((r) => Object.keys(r.values).length > 0);
if (rows.length < 1000) throw new Error(`only ${rows.length} rows`);
await sql.begin(async (tx) => {
await tx`delete from stats.owid_energy`;
await insertChunks(tx, 'owid_energy', rows);
});
const series: SeriesRow[] = [];
const obs: ObsRow[] = [];
const seen = new Set<string>();
for (const r of rows) {
const region = regionOf(r.iso3 ?? '', r.country);
if (!region) continue;
for (const [k, v] of Object.entries(r.values)) {
const meta = ENERGY_COLUMNS[k];
if (!meta.series) continue;
const id = `owid:energy:${k}:${region}`;
if (!seen.has(id)) {
seen.add(id);
series.push({
series_id: id,
source_id: 'owid',
source_code: `energy/${k}/${r.country}`,
title: meta.title,
unit: meta.unit,
freq: 'A',
region_code: region,
theme: 'energy',
meta: { url: 'https://github.com/owid/energy-data' }
});
}
obs.push({ series_id: id, period: `${r.year}-01-01`, value: v, status: null });
}
}
await writeSeries(series, obs);
log(`owid energy: ${rows.length} rows`);
total += rows.length;
} catch (error) {
note(`energy: ${error instanceof Error ? error.message : String(error)}`);
}
}
return total;
});
}

View File

@@ -0,0 +1,163 @@
// A13 Rijksfinanciën: the central-government budget tables (begroting OWB, suppletoire
// begrotingen O1/O2, jaarverslag JV) per year, down to regeling level. Open data starts in 2015.
// Normalised: annual totals per chapter and overall, from the most final phase available.
import {
getJson,
insertChunks,
log,
run,
sql,
writeSeries,
type ObsRow,
type SeriesRow
} from './common';
const FIRST_YEAR = 2015;
const PHASES = ['OWB', 'O1', 'O2', 'JV'] as const;
const PHASE_RANK: Record<string, number> = { OWB: 1, O1: 2, O2: 3, JV: 4 };
type RfRow = {
index: string;
jaar: number;
fase: string;
hoofdstuk_naam: string;
hoofdstuk_nummer: string;
artikel_naam: string | null;
artikel_nummer: string | null;
vuo: string;
bedrag: number | null;
[key: string]: unknown;
};
export async function rijksfinancien(opts: { backfill?: boolean; job: string }) {
await run('rijksfinancien', opts.job, async (note) => {
const now = new Date().getUTCFullYear();
// Refresh: last year (JV lands in May) up to next year (OWB on Prinsjesdag).
const years = [];
for (let y = opts.backfill ? FIRST_YEAR : now - 1; y <= now + 1; y++) years.push(y);
let total = 0;
for (const year of years) {
for (const phase of PHASES) {
try {
const data = await getJson<RfRow[] | Record<string, unknown>>(
`https://www.rijksfinancien.nl/open-data/api/json/budgettaire_tabellen?year=${year}&phase=${phase}`
);
const list = Array.isArray(data) ? data : [];
if (!list.length) continue;
const rows = list.map((r, i) => {
const {
index,
jaar,
fase,
hoofdstuk_naam,
hoofdstuk_nummer,
artikel_naam,
artikel_nummer,
vuo,
bedrag,
...detail
} = r;
void jaar;
void fase;
return {
year,
phase,
row_key: index ?? `row-${i}`,
vuo: (vuo || '?').slice(0, 1),
chapter: hoofdstuk_nummer ?? '?',
chapter_name: hoofdstuk_naam ?? '',
article: artikel_nummer,
article_name: artikel_naam,
detail,
amount_eur_k: bedrag
};
});
await sql.begin(async (tx) => {
await tx`delete from stats.rf_budget_table where year = ${year} and phase = ${phase}`;
await insertChunks(tx, 'rf_budget_table', rows, 'on conflict do nothing');
});
log(`rijksfinancien ${year} ${phase}: ${rows.length} rows`);
total += rows.length;
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (!/HTTP 404/.test(msg)) note(`${year} ${phase}: ${msg}`);
}
}
}
await normaliseRf();
return total;
});
}
/** Annual U (expenditure) and O (revenue) per chapter + totals, from the most final phase. */
export async function normaliseRf() {
const agg = await sql<
{
year: number;
phase: string;
vuo: string;
chapter: string;
chapter_name: string;
eur_bn: number;
}[]
>`
select year, phase, vuo, chapter, max(chapter_name) as chapter_name, sum(amount_eur_k) / 1e6 as eur_bn
from stats.rf_budget_table where vuo in ('U', 'O') group by 1, 2, 3, 4`;
const best = new Map<string, (typeof agg)[number]>();
for (const r of agg) {
const key = `${r.vuo}|${r.chapter}|${r.year}`;
const prev = best.get(key);
if (!prev || PHASE_RANK[r.phase] > PHASE_RANK[prev.phase]) best.set(key, r);
}
const series = new Map<string, SeriesRow>();
const obs: ObsRow[] = [];
const totals = new Map<string, { value: number; phase: string }>();
const label = { U: 'expenditure', O: 'revenue' } as Record<string, string>;
for (const r of best.values()) {
const id = `rf:${r.vuo}:${r.chapter}`;
series.set(id, {
series_id: id,
source_id: 'rijksfinancien',
source_code: `${r.vuo}/${r.chapter}`,
title: `${r.chapter_name}: ${label[r.vuo]}`,
unit: 'EUR bn',
freq: 'A',
region_code: 'NLD',
theme: 'budget',
meta: {
chapter: r.chapter,
chapterName: r.chapter_name,
vuo: r.vuo,
url: 'https://www.rijksfinancien.nl/open-data'
}
});
obs.push({
series_id: id,
period: `${r.year}-01-01`,
value: Number(r.eur_bn),
status: r.phase
});
const tk = `${r.vuo}|${r.year}`;
const t = totals.get(tk) ?? { value: 0, phase: r.phase };
t.value += Number(r.eur_bn);
if (PHASE_RANK[r.phase] < PHASE_RANK[t.phase]) t.phase = r.phase;
totals.set(tk, t);
}
for (const [tk, t] of totals) {
const [vuo, year] = tk.split('|');
const id = `rf:${vuo}:total`;
series.set(id, {
series_id: id,
source_id: 'rijksfinancien',
source_code: `${vuo}/total`,
title: `Rijksbegroting ${label[vuo]}, all chapters`,
unit: 'EUR bn',
freq: 'A',
region_code: 'NLD',
theme: 'budget',
meta: { vuo, url: 'https://www.rijksfinancien.nl/open-data' }
});
obs.push({ series_id: id, period: `${year}-01-01`, value: t.value, status: t.phase });
}
return writeSeries([...series.values()], obs);
}

View File

@@ -0,0 +1,86 @@
// A12 USAspending: budgetary resources, obligations and outlays per top-tier agency and fiscal
// year (one call for the agency list, one per agency for its history). Small: ~110 agencies.
import {
getJson,
insertChunks,
log,
run,
sql,
writeSeries,
type ObsRow,
type SeriesRow
} from './common';
const BASE = 'https://api.usaspending.gov/api/v2';
type Agency = { toptier_code: string; agency_name: string; abbreviation: string | null };
type Resources = {
agency_data_by_year: {
fiscal_year: number;
agency_budgetary_resources: number | null;
agency_total_obligated: number | null;
agency_total_outlayed: number | null;
}[];
};
export async function usaspending(opts: { job: string }) {
await run('usaspending', opts.job, async (note) => {
const { results } = await getJson<{ results: Agency[] }>(
`${BASE}/references/toptier_agencies/`
);
const rows: Record<string, unknown>[] = [];
for (const a of results) {
try {
const r = await getJson<Resources>(`${BASE}/agency/${a.toptier_code}/budgetary_resources/`);
for (const y of r.agency_data_by_year ?? [])
rows.push({
toptier_code: a.toptier_code,
fiscal_year: y.fiscal_year,
agency_name: a.agency_name,
abbreviation: a.abbreviation,
budgetary_resources: y.agency_budgetary_resources,
obligated: y.agency_total_obligated,
outlayed: y.agency_total_outlayed
});
} catch (error) {
note(`${a.toptier_code}: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (!rows.length) throw new Error('no rows');
await sql.begin(async (tx) => {
await tx`delete from stats.usas_agency_fy`;
await insertChunks(tx, 'usas_agency_fy', rows);
});
const series: SeriesRow[] = [];
const obs: ObsRow[] = [];
for (const a of results) {
const id = `usas:outlays:${a.toptier_code}`;
const mine = rows.filter((r) => r.toptier_code === a.toptier_code);
if (!mine.length) continue;
series.push({
series_id: id,
source_id: 'usaspending',
source_code: a.toptier_code,
title: `Outlays: ${a.agency_name}`,
unit: 'USD bn',
freq: 'A',
region_code: 'USA',
theme: 'budget',
meta: {
abbreviation: a.abbreviation,
url: `https://www.usaspending.gov/agency/${a.toptier_code}`
}
});
for (const r of mine)
if (r.outlayed != null)
obs.push({
series_id: id,
period: `${r.fiscal_year}-01-01`,
value: Number(r.outlayed) / 1e9,
status: null
});
}
await writeSeries(series, obs);
log(`usaspending: ${rows.length} agency-years`);
return rows.length;
});
}

307
scripts/stats/ustreasury.ts Normal file
View File

@@ -0,0 +1,307 @@
// 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);
}

111
scripts/stats/worldbank.ts Normal file
View File

@@ -0,0 +1,111 @@
// A3 World Bank World Development Indicators: one call per indicator for every compared country
// (plus EU and World aggregates) → stats.wb_indicator, normalised as `wb:<indicator>:<ISO3>`.
import {
COMPARE_ISO3,
getJson,
insertChunks,
log,
run,
sql,
writeSeries,
type ObsRow,
type SeriesRow
} from './common';
const COUNTRIES = [...COMPARE_ISO3, 'EUU', 'WLD'];
// EUU (World Bank code for the EU) is stored as EU27 in the normalised layer.
const regionOf = (iso3: string) => (iso3 === 'EUU' ? 'EU27' : iso3);
export const WB_INDICATORS: { id: string; title: string; unit: string; theme: string }[] = [
{ id: 'NY.GDP.PCAP.KD', title: 'GDP per capita', unit: 'constant 2015 USD', theme: 'countries' },
{
id: 'NY.GDP.PCAP.PP.KD',
title: 'GDP per capita, PPP',
unit: 'constant 2021 international $',
theme: 'countries'
},
{ id: 'NY.GDP.MKTP.CD', title: 'GDP', unit: 'current USD', theme: 'countries' },
{ id: 'NY.GDP.MKTP.KD.ZG', title: 'GDP growth', unit: '%', theme: 'countries' },
{ id: 'FP.CPI.TOTL.ZG', title: 'Inflation, consumer prices', unit: '%', theme: 'countries' },
{ id: 'SP.POP.TOTL', title: 'Population', unit: 'people', theme: 'countries' },
{ id: 'SM.POP.NETM', title: 'Net migration', unit: 'people', theme: 'countries' },
{
id: 'NE.TRD.GNFS.ZS',
title: 'Trade (exports + imports)',
unit: '% of GDP',
theme: 'countries'
},
{
id: 'TM.TAX.MRCH.WM.AR.ZS',
title: 'Applied tariff rate, weighted mean, all products',
unit: '%',
theme: 'economy'
},
{ id: 'EG.IMP.CONS.ZS', title: 'Energy imports, net', unit: '% of energy use', theme: 'energy' },
{
id: 'GC.REV.XGRT.GD.ZS',
title: 'Government revenue, excluding grants',
unit: '% of GDP',
theme: 'budget'
},
{ id: 'GC.XPN.TOTL.GD.ZS', title: 'Government expense', unit: '% of GDP', theme: 'budget' },
{ id: 'GC.DOD.TOTL.GD.ZS', title: 'Central government debt', unit: '% of GDP', theme: 'budget' }
];
type WbRow = {
countryiso3code: string;
date: string;
value: number | null;
indicator: { id: string };
};
export async function worldbank(opts: { only?: string; job: string }) {
await run('worldbank', opts.job, async (note) => {
let total = 0;
for (const ind of WB_INDICATORS) {
if (opts.only && !opts.only.split(',').includes(ind.id)) continue;
try {
const url = `https://api.worldbank.org/v2/country/${COUNTRIES.join(';')}/indicator/${ind.id}?format=json&per_page=20000`;
const body = await getJson<[{ pages: number }, WbRow[] | null]>(url);
const rows = (body[1] ?? [])
.filter((r) => r.countryiso3code && /^\d{4}$/.test(r.date))
.map((r) => ({
indicator_id: ind.id,
country_iso3: r.countryiso3code,
year: Number(r.date),
value: r.value
}));
if (!rows.length) throw new Error('no rows');
await sql.begin(async (tx) => {
await tx`delete from stats.wb_indicator where indicator_id = ${ind.id}`;
await insertChunks(tx, 'wb_indicator', rows);
});
const series: SeriesRow[] = [];
const obs: ObsRow[] = [];
for (const iso3 of new Set(rows.map((r) => r.country_iso3))) {
const id = `wb:${ind.id}:${regionOf(iso3)}`;
series.push({
series_id: id,
source_id: 'worldbank',
source_code: `${ind.id}/${iso3}`,
title: ind.title,
unit: ind.unit,
freq: 'A',
region_code: regionOf(iso3),
theme: ind.theme,
meta: { url: `https://data.worldbank.org/indicator/${ind.id}` }
});
for (const r of rows)
if (r.country_iso3 === iso3 && r.value != null)
obs.push({ series_id: id, period: `${r.year}-01-01`, value: r.value, status: null });
}
await writeSeries(series, obs);
log(`worldbank ${ind.id}: ${rows.length} rows`);
total += rows.length;
} catch (error) {
note(`${ind.id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return total;
});
}