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
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
This commit is contained in:
@@ -9,6 +9,13 @@ statistics (no API keys) into the `stats` schema with `scripts/stats-fetch.ts`:
|
|||||||
| `eurostat` | `stats-eurostat.timer` | Mondays 06:20 | Eurostat JSON-stat datasets (energy prices, yields, HICP, government finance…) |
|
| `eurostat` | `stats-eurostat.timer` | Mondays 06:20 | Eurostat JSON-stat datasets (energy prices, yields, HICP, government finance…) |
|
||||||
| `fred` | `stats-fred.timer` | daily 07:20 | FRED graph CSV (Brent, TTF, Fed, Treasuries, US energy prices, indices) |
|
| `fred` | `stats-fred.timer` | daily 07:20 | FRED graph CSV (Brent, TTF, Fed, Treasuries, US energy prices, indices) |
|
||||||
| `bis` | `stats-bis.timer` | daily 07:50 | BIS central bank policy rates, monthly + daily |
|
| `bis` | `stats-bis.timer` | daily 07:50 | BIS central bank policy rates, monthly + daily |
|
||||||
|
| `worldbank` | `stats-worldbank.timer` | Mondays 04:15 | World Bank WDI indicators for the compared countries |
|
||||||
|
| `imf` | `stats-imf.timer` | 1st of the month 04:05 | IMF WEO (DataMapper), stored per WEO vintage with forecast flags |
|
||||||
|
| `owid` | `stats-owid.timer` | 2nd of the month 04:35 | Our World in Data: Maddison GDP per capita, energy dataset |
|
||||||
|
| `energycharts` | `stats-energycharts.timer` | hourly at :19 | Energy-Charts NL day-ahead prices + cross-border flows (last 3 days; `--backfill` from 2015) |
|
||||||
|
| `rijksfinancien` | `stats-rijksfinancien.timer` | 3rd of the month 05:05 | Rijksfinanciën budget tables, last year to next year (`--backfill` from 2015) |
|
||||||
|
| `ustreasury` | `stats-ustreasury.timer` | daily 23:25 | US Treasury MTS receipts/outlays + Debt to the Penny (last 400 days; `--backfill` all) |
|
||||||
|
| `usaspending` | `stats-usaspending.timer` | Sundays 06:10 | USAspending budgetary resources per agency and fiscal year |
|
||||||
|
|
||||||
Each timer starts `stats-collector@<source>.service`, which runs
|
Each timer starts `stats-collector@<source>.service`, which runs
|
||||||
`~/.local/bin/stats-collector-run.sh <source>`: it updates a detached checkout of `origin/main`
|
`~/.local/bin/stats-collector-run.sh <source>`: it updates a detached checkout of `origin/main`
|
||||||
|
|||||||
11
deploy/stats-collector/stats-energycharts.timer
Normal file
11
deploy/stats-collector/stats-energycharts.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Energy-Charts hourly at :19 (off ned-collector's :07/:37)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* *:19:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=2min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@energycharts.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-imf.timer
Normal file
11
deploy/stats-collector/stats-imf.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: IMF WEO monthly (new rounds in April and October)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-01 04:05:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@imf.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-owid.timer
Normal file
11
deploy/stats-collector/stats-owid.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Our World in Data monthly (Maddison, energy dataset)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-02 04:35:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@owid.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-rijksfinancien.timer
Normal file
11
deploy/stats-collector/stats-rijksfinancien.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Rijksfinanciën monthly (budget tables)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-03 05:05:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@rijksfinancien.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-usaspending.timer
Normal file
11
deploy/stats-collector/stats-usaspending.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: USAspending weekly (Sunday)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=Sun *-*-* 06:10:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@usaspending.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-ustreasury.timer
Normal file
11
deploy/stats-collector/stats-ustreasury.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: US Treasury Fiscal Data daily (after the US afternoon release)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 23:25:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@ustreasury.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-worldbank.timer
Normal file
11
deploy/stats-collector/stats-worldbank.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: World Bank WDI weekly (Monday)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=Mon *-*-* 04:15:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@worldbank.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
119
drizzle/20261025210436_stats_step2/migration.sql
Normal file
119
drizzle/20261025210436_stats_step2/migration.sql
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
CREATE TABLE "stats"."ec_crossborder" (
|
||||||
|
"country" text,
|
||||||
|
"neighbour" text,
|
||||||
|
"ts" timestamp with time zone,
|
||||||
|
"gw" double precision,
|
||||||
|
CONSTRAINT "ec_crossborder_pkey" PRIMARY KEY("country","neighbour","ts")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "stats"."ec_dayahead_price" (
|
||||||
|
"bzn" text,
|
||||||
|
"ts" timestamp with time zone,
|
||||||
|
"eur_mwh" double precision,
|
||||||
|
CONSTRAINT "ec_dayahead_price_pkey" PRIMARY KEY("bzn","ts")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "stats"."imf_weo" (
|
||||||
|
"vintage" text,
|
||||||
|
"indicator" text,
|
||||||
|
"country_iso3" text,
|
||||||
|
"year" integer,
|
||||||
|
"value" double precision,
|
||||||
|
"is_forecast" boolean NOT NULL,
|
||||||
|
CONSTRAINT "imf_weo_pkey" PRIMARY KEY("vintage","indicator","country_iso3","year")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "stats"."owid_energy" (
|
||||||
|
"country" text,
|
||||||
|
"iso3" text,
|
||||||
|
"year" integer,
|
||||||
|
"values" jsonb NOT NULL,
|
||||||
|
CONSTRAINT "owid_energy_pkey" PRIMARY KEY("country","year")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "stats"."owid_maddison" (
|
||||||
|
"entity" text,
|
||||||
|
"iso3" text,
|
||||||
|
"year" integer,
|
||||||
|
"gdp_pc" double precision,
|
||||||
|
CONSTRAINT "owid_maddison_pkey" PRIMARY KEY("entity","year")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "stats"."rf_budget_table" (
|
||||||
|
"year" integer,
|
||||||
|
"phase" text,
|
||||||
|
"row_key" text,
|
||||||
|
"vuo" char(1) NOT NULL,
|
||||||
|
"chapter" text NOT NULL,
|
||||||
|
"chapter_name" text NOT NULL,
|
||||||
|
"article" text,
|
||||||
|
"article_name" text,
|
||||||
|
"detail" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
"amount_eur_k" double precision,
|
||||||
|
CONSTRAINT "rf_budget_table_pkey" PRIMARY KEY("year","phase","row_key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "stats"."usas_agency_fy" (
|
||||||
|
"toptier_code" text,
|
||||||
|
"fiscal_year" integer,
|
||||||
|
"agency_name" text NOT NULL,
|
||||||
|
"abbreviation" text,
|
||||||
|
"budgetary_resources" double precision,
|
||||||
|
"obligated" double precision,
|
||||||
|
"outlayed" double precision,
|
||||||
|
CONSTRAINT "usas_agency_fy_pkey" PRIMARY KEY("toptier_code","fiscal_year")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "stats"."ust_debt_daily" (
|
||||||
|
"record_date" date PRIMARY KEY,
|
||||||
|
"debt_held_public" double precision,
|
||||||
|
"intragov_holdings" double precision,
|
||||||
|
"total_debt" double precision
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "stats"."ust_mts_outlays" (
|
||||||
|
"record_date" date,
|
||||||
|
"classification_id" text,
|
||||||
|
"parent_id" text,
|
||||||
|
"description" text NOT NULL,
|
||||||
|
"level" integer,
|
||||||
|
"print_order" integer,
|
||||||
|
"net_month" double precision,
|
||||||
|
"net_fytd" double precision,
|
||||||
|
"gross_month" double precision,
|
||||||
|
CONSTRAINT "ust_mts_outlays_pkey" PRIMARY KEY("record_date","classification_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "stats"."ust_mts_receipts" (
|
||||||
|
"record_date" date,
|
||||||
|
"classification_id" text,
|
||||||
|
"parent_id" text,
|
||||||
|
"description" text NOT NULL,
|
||||||
|
"level" integer,
|
||||||
|
"print_order" integer,
|
||||||
|
"net_month" double precision,
|
||||||
|
"net_fytd" double precision,
|
||||||
|
"gross_month" double precision,
|
||||||
|
CONSTRAINT "ust_mts_receipts_pkey" PRIMARY KEY("record_date","classification_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "stats"."wb_indicator" (
|
||||||
|
"indicator_id" text,
|
||||||
|
"country_iso3" text,
|
||||||
|
"year" integer,
|
||||||
|
"value" double precision,
|
||||||
|
CONSTRAINT "wb_indicator_pkey" PRIMARY KEY("indicator_id","country_iso3","year")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "rf_budget_table_chapter_idx" ON "stats"."rf_budget_table" ("year","vuo","chapter");--> statement-breakpoint
|
||||||
|
-- Hand-written below this line (not generated by drizzle-kit).
|
||||||
|
-- Same grants as stats_core (default privileges already cover new tables; kept idempotent).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA stats TO personal_app;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA stats TO personal_ro;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
33023
drizzle/20261025210436_stats_step2/snapshot.json
Normal file
33023
drizzle/20261025210436_stats_step2/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -177,7 +177,7 @@ try {
|
|||||||
throw new Error(`groceries schema grants are wrong: ${JSON.stringify(groceries)}`);
|
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
|
// 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.
|
// 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`
|
const [stats] = await client`
|
||||||
select
|
select
|
||||||
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
||||||
|
|||||||
@@ -6,12 +6,22 @@
|
|||||||
// bun scripts/stats-fetch.ts eurostat [--only=nrg_pc_204]
|
// bun scripts/stats-fetch.ts eurostat [--only=nrg_pc_204]
|
||||||
// bun scripts/stats-fetch.ts fred [--only=DCOILBRENTEU]
|
// bun scripts/stats-fetch.ts fred [--only=DCOILBRENTEU]
|
||||||
// bun scripts/stats-fetch.ts bis
|
// 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
|
// bun scripts/stats-fetch.ts normalise-cbs rebuild CBS chart series from the raw rows
|
||||||
import { ensureMeta, sql } from './stats/common';
|
import { ensureMeta, sql } from './stats/common';
|
||||||
import { cbs, normaliseCbs } from './stats/cbs';
|
import { cbs, normaliseCbs } from './stats/cbs';
|
||||||
import { eurostat } from './stats/eurostat';
|
import { eurostat } from './stats/eurostat';
|
||||||
import { fred } from './stats/fred';
|
import { fred } from './stats/fred';
|
||||||
import { bis } from './stats/bis';
|
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 [source, ...args] = process.argv.slice(2);
|
||||||
const flag = (name: string) => args.includes(`--${name}`);
|
const flag = (name: string) => args.includes(`--${name}`);
|
||||||
@@ -36,8 +46,31 @@ try {
|
|||||||
case 'bis':
|
case 'bis':
|
||||||
await bis({ job });
|
await bis({ job });
|
||||||
break;
|
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:
|
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 {
|
} finally {
|
||||||
await sql.end({ timeout: 5 });
|
await sql.end({ timeout: 5 });
|
||||||
|
|||||||
@@ -23,7 +23,15 @@ const hostGapMs: Record<string, number> = {
|
|||||||
'sdmx.oecd.org': 3100,
|
'sdmx.oecd.org': 3100,
|
||||||
'fred.stlouisfed.org': 1000,
|
'fred.stlouisfed.org': 1000,
|
||||||
'ec.europa.eu': 500,
|
'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>();
|
const lastHit = new Map<string, number>();
|
||||||
|
|
||||||
@@ -127,6 +135,64 @@ export const SOURCES = [
|
|||||||
licence: 'Free reuse with attribution',
|
licence: 'Free reuse with attribution',
|
||||||
key_required: false,
|
key_required: false,
|
||||||
attribution: 'Source: BIS, central bank policy rates (WS_CBPOL)'
|
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;
|
] 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: 'GBR', name: 'United Kingdom', kind: 'country', parent: 'WLD' },
|
||||||
{ region_code: 'JPN', name: 'Japan', kind: 'country', parent: 'WLD' },
|
{ region_code: 'JPN', name: 'Japan', kind: 'country', parent: 'WLD' },
|
||||||
{ region_code: 'IND', name: 'India', 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: 'CR28', name: 'Oost-Zuid-Holland (COROP)', kind: 'corop', parent: 'NLD' },
|
||||||
{ region_code: 'GM0513', name: 'Gouda', kind: 'municipality', parent: 'CR28' },
|
{ region_code: 'GM0513', name: 'Gouda', kind: 'municipality', parent: 'CR28' },
|
||||||
{ region_code: 'GM0627', name: 'Waddinxveen', 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' }
|
{ 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). */
|
/** Gouda and its house-hunting ring (CBS gemeente codes). */
|
||||||
export const GOUDA_RING = ['GM0513', 'GM0627', 'GM1901', 'GM1892', 'GM1931', 'GM0484', 'GM0632'];
|
export const GOUDA_RING = ['GM0513', 'GM0627', 'GM1901', 'GM1892', 'GM1931', 'GM0484', 'GM0632'];
|
||||||
|
|
||||||
|
|||||||
120
scripts/stats/energycharts.ts
Normal file
120
scripts/stats/energycharts.ts
Normal 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
128
scripts/stats/imf.ts
Normal 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
218
scripts/stats/owid.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
163
scripts/stats/rijksfinancien.ts
Normal file
163
scripts/stats/rijksfinancien.ts
Normal 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);
|
||||||
|
}
|
||||||
86
scripts/stats/usaspending.ts
Normal file
86
scripts/stats/usaspending.ts
Normal 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
307
scripts/stats/ustreasury.ts
Normal 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
111
scripts/stats/worldbank.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -202,3 +202,155 @@ export const statsBisPolicyRate = statsSchema.table(
|
|||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.refArea, t.freq, t.period] })]
|
(t) => [primaryKey({ columns: [t.refArea, t.freq, t.period] })]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── A3 World Bank indicators, A4 IMF WEO (DataMapper) ─────────────────────────────────────
|
||||||
|
|
||||||
|
export const statsWbIndicator = statsSchema.table(
|
||||||
|
'wb_indicator',
|
||||||
|
{
|
||||||
|
indicatorId: text('indicator_id').notNull(),
|
||||||
|
countryIso3: text('country_iso3').notNull(),
|
||||||
|
year: integer('year').notNull(),
|
||||||
|
value: doublePrecision('value')
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.indicatorId, t.countryIso3, t.year] })]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const statsImfWeo = statsSchema.table(
|
||||||
|
'imf_weo',
|
||||||
|
{
|
||||||
|
/** WEO round the values come from, e.g. '2026-10' (October 2026 WEO). */
|
||||||
|
vintage: text('vintage').notNull(),
|
||||||
|
indicator: text('indicator').notNull(),
|
||||||
|
countryIso3: text('country_iso3').notNull(),
|
||||||
|
year: integer('year').notNull(),
|
||||||
|
value: doublePrecision('value'),
|
||||||
|
isForecast: boolean('is_forecast').notNull()
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.vintage, t.indicator, t.countryIso3, t.year] })]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── A9 Our World in Data (Maddison GDP per capita, energy dataset) ───────────────────────
|
||||||
|
|
||||||
|
export const statsOwidMaddison = statsSchema.table(
|
||||||
|
'owid_maddison',
|
||||||
|
{
|
||||||
|
entity: text('entity').notNull(),
|
||||||
|
/** null for OWID regions (Western Europe, World, …). */
|
||||||
|
iso3: text('iso3'),
|
||||||
|
year: integer('year').notNull(),
|
||||||
|
/** International-$ at 2011 prices. */
|
||||||
|
gdpPc: doublePrecision('gdp_pc')
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.entity, t.year] })]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const statsOwidEnergy = statsSchema.table(
|
||||||
|
'owid_energy',
|
||||||
|
{
|
||||||
|
country: text('country').notNull(),
|
||||||
|
iso3: text('iso3'),
|
||||||
|
year: integer('year').notNull(),
|
||||||
|
/** Selected OWID columns as-is: {"solar_share_elec": 21.3, "energy_per_capita": 40000, …}. */
|
||||||
|
values: jsonb('values').$type<Record<string, number>>().notNull()
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.country, t.year] })]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── A10 Energy-Charts (Fraunhofer ISE): day-ahead prices, cross-border physical flows ─────
|
||||||
|
|
||||||
|
export const statsEcDayaheadPrice = statsSchema.table(
|
||||||
|
'ec_dayahead_price',
|
||||||
|
{
|
||||||
|
bzn: text('bzn').notNull(),
|
||||||
|
ts: timestamptz('ts').notNull(),
|
||||||
|
eurMwh: doublePrecision('eur_mwh')
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.bzn, t.ts] })]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const statsEcCrossborder = statsSchema.table(
|
||||||
|
'ec_crossborder',
|
||||||
|
{
|
||||||
|
/** Lower-case Energy-Charts country code, e.g. 'nl'. */
|
||||||
|
country: text('country').notNull(),
|
||||||
|
/** Neighbour name as Energy-Charts reports it ('Belgium', …, 'sum'). */
|
||||||
|
neighbour: text('neighbour').notNull(),
|
||||||
|
ts: timestamptz('ts').notNull(),
|
||||||
|
/** GW; positive = import into `country`, negative = export. */
|
||||||
|
gw: doublePrecision('gw')
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.country, t.neighbour, t.ts] })]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── A13 Rijksfinanciën (NL central-government budget tables) ──────────────────────────────
|
||||||
|
|
||||||
|
export const statsRfBudgetTable = statsSchema.table(
|
||||||
|
'rf_budget_table',
|
||||||
|
{
|
||||||
|
year: integer('year').notNull(),
|
||||||
|
/** OWB (budget), O1/O2 (supplementary budgets), JV (annual report). */
|
||||||
|
phase: text('phase').notNull(),
|
||||||
|
/** Rijksfinanciën row index, unique within year + phase (includes chapter/article/vuo). */
|
||||||
|
rowKey: text('row_key').notNull(),
|
||||||
|
/** U = expenditure, O = revenue, V = commitments. */
|
||||||
|
vuo: char('vuo', { length: 1 }).notNull(),
|
||||||
|
chapter: text('chapter').notNull(),
|
||||||
|
chapterName: text('chapter_name').notNull(),
|
||||||
|
article: text('article'),
|
||||||
|
articleName: text('article_name'),
|
||||||
|
detail: jsonb('detail').$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
/** Thousand euro. */
|
||||||
|
amountEurK: doublePrecision('amount_eur_k')
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
primaryKey({ columns: [t.year, t.phase, t.rowKey] }),
|
||||||
|
index('rf_budget_table_chapter_idx').on(t.year, t.vuo, t.chapter)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── A11 US Treasury Fiscal Data (Monthly Treasury Statement, Debt to the Penny) ───────────
|
||||||
|
|
||||||
|
const mtsColumns = {
|
||||||
|
recordDate: date('record_date', { mode: 'string' }).notNull(),
|
||||||
|
classificationId: text('classification_id').notNull(),
|
||||||
|
parentId: text('parent_id'),
|
||||||
|
description: text('description').notNull(),
|
||||||
|
level: integer('level'),
|
||||||
|
printOrder: integer('print_order'),
|
||||||
|
/** Net amount for the month and fiscal year to date, USD. */
|
||||||
|
netMonth: doublePrecision('net_month'),
|
||||||
|
netFytd: doublePrecision('net_fytd'),
|
||||||
|
grossMonth: doublePrecision('gross_month')
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statsUstMtsReceipts = statsSchema.table('ust_mts_receipts', mtsColumns, (t) => [
|
||||||
|
primaryKey({ columns: [t.recordDate, t.classificationId] })
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const statsUstMtsOutlays = statsSchema.table('ust_mts_outlays', { ...mtsColumns }, (t) => [
|
||||||
|
primaryKey({ columns: [t.recordDate, t.classificationId] })
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const statsUstDebtDaily = statsSchema.table('ust_debt_daily', {
|
||||||
|
recordDate: date('record_date', { mode: 'string' }).primaryKey(),
|
||||||
|
debtHeldPublic: doublePrecision('debt_held_public'),
|
||||||
|
intragovHoldings: doublePrecision('intragov_holdings'),
|
||||||
|
totalDebt: doublePrecision('total_debt')
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A12 USAspending (budgetary resources per top-tier agency and fiscal year) ─────────────
|
||||||
|
|
||||||
|
export const statsUsasAgencyFy = statsSchema.table(
|
||||||
|
'usas_agency_fy',
|
||||||
|
{
|
||||||
|
toptierCode: text('toptier_code').notNull(),
|
||||||
|
fiscalYear: integer('fiscal_year').notNull(),
|
||||||
|
agencyName: text('agency_name').notNull(),
|
||||||
|
abbreviation: text('abbreviation'),
|
||||||
|
budgetaryResources: doublePrecision('budgetary_resources'),
|
||||||
|
obligated: doublePrecision('obligated'),
|
||||||
|
outlayed: doublePrecision('outlayed')
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.toptierCode, t.fiscalYear] })]
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user