stats: core-7 economy page with CPI index levels, rebase and categories
All checks were successful
Deploy production / deploy (push) Successful in 1m13s

- /stats/economy defaults multi-country charts to CAN KOR CHN JPN USA NLD CUW,
  with add-a-country chips and an EU 27 comparison line
- CPI index levels rebased to a chosen base year, index/YoY toggle,
  categories (food, energy, housing, core) where the source has them
- new no-key collectors: oecd (CPI categories, 10y yields), statcan (CPI);
  bis WS_LONG_CPI, FRED US CPI categories, Eurostat prc_hicp_minr, WB FP.CPI.TOTL
- Canada added as a region; CBS Curacao fallbacks for growth/unemployment
- timers stats-oecd and stats-statcan
This commit is contained in:
2026-10-26 00:34:27 +00:00
parent 28140bd425
commit 45841d6f6c
18 changed files with 926 additions and 122 deletions

View File

@@ -6,10 +6,12 @@ statistics into the `stats` schema with `scripts/stats-fetch.ts`:
| source | timer | schedule (Europe/Amsterdam) | what |
| ---------------- | ---------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cbs` | `stats-cbs.timer` | nightly 05:40 | CBS StatLine OData v4: checks every table's `Modified`, re-pulls changed ones, flags discontinued tables (base-year swaps) |
| `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) |
| `bis` | `stats-bis.timer` | daily 07:50 | BIS central bank policy rates, monthly + daily |
| `eurostat` | `stats-eurostat.timer` | Mondays 06:20 | Eurostat JSON-stat datasets (energy prices, yields, HICP index 2015 = 100 + annual rates by ECOICOP 2 category (`prc_hicp_minr`), government finance…) |
| `fred` | `stats-fred.timer` | daily 07:20 | FRED graph CSV (Brent, TTF, Fed, Treasuries, US energy prices, indices, US CPI by category) |
| `bis` | `stats-bis.timer` | daily 07:50 | BIS central bank policy rates (monthly + daily) and long CPI series `WS_LONG_CPI` (index 2010 = 100) for the core-7 and comparison countries |
| `worldbank` | `stats-worldbank.timer` | Mondays 04:15 | World Bank WDI indicators for the compared countries |
| `oecd` | `stats-oecd.timer` | Thursdays 06:35 | OECD Data Explorer SDMX CSV (no key): CPI index by category for Korea, Japan (ends 2021-06) and China (total + food), 10-year government bond yields |
| `statcan` | `stats-statcan.timer` | daily 16:10 | Statistics Canada WDS table 18-10-0004-01: CPI all items, food, shelter, energy, core (2002 = 100) |
| `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) |
@@ -27,6 +29,7 @@ statistics into the `stats` schema with `scripts/stats-fetch.ts`:
| `cbscw` | `stats-cbscw.timer` | Tuesdays 05:50 | CBS Curaçao: scrapes the topic pages (cbs.cw), matches each table's file by regex, re-imports only when its sha256 changes (`stats.cw_cbs_file`); CPI from PDF (pdfjs), GDP, government finance, population since 1900, LFS, tourism from xlsx |
| `cwintl` | `stats-cwintl.timer` | Wednesdays 05:50 | Curaçao from international APIs: ILOSTAT SDMX (labour, with retries), OWID grapher CSVs (population, life expectancy, median age, CO₂), UN SDG area 531, UN Comtrade public preview reporter 531. EU trade with Curaçao (Eurostat `ext_lt_maineu`) runs with the `eurostat` timer |
| – | – | manual | CBCS (centralbank.cw) blocks scripts with a Cloudflare challenge: download the BoP xlsx by hand and load it into `stats.cw_cbcs_bop` (period, item, mln_ang) |
| – | – | manual | China CPI by category beyond food (NBS, data.stats.gov.cn) has no stable no-key API: not collected. Japan e-Stat needs an app id: skipped |
Each timer starts `stats-collector@<source>.service`, which runs
`~/.local/bin/stats-collector-run.sh <source>`: it updates a detached checkout of `origin/main`

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: OECD SDMX CPI categories and 10-year yields, Thursdays
[Timer]
OnCalendar=Thu *-*-* 06:35:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@oecd.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=/stats collector: Statistics Canada CPI daily
[Timer]
OnCalendar=*-*-* 16:10:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
Unit=stats-collector@statcan.service
[Install]
WantedBy=timers.target

View File

@@ -6,6 +6,8 @@
// 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 oecd [--only=cpi,irlt] OECD SDMX (CPI categories, 10y yields)
// bun scripts/stats-fetch.ts statcan Statistics Canada CPI (WDS)
// bun scripts/stats-fetch.ts worldbank|imf|owid [--only=…]
// bun scripts/stats-fetch.ts energycharts|rijksfinancien|ustreasury [--backfill]
// bun scripts/stats-fetch.ts usaspending
@@ -21,6 +23,8 @@ import { cbs, normaliseCbs } from './stats/cbs';
import { eurostat } from './stats/eurostat';
import { fred } from './stats/fred';
import { bis } from './stats/bis';
import { oecd } from './stats/oecd';
import { statcan } from './stats/statcan';
import { worldbank } from './stats/worldbank';
import { imf } from './stats/imf';
import { owid } from './stats/owid';
@@ -63,6 +67,12 @@ try {
case 'bis':
await bis({ job });
break;
case 'oecd':
await oecd({ only: value('only'), job });
break;
case 'statcan':
await statcan({ job });
break;
case 'worldbank':
await worldbank({ only: value('only'), job });
break;
@@ -119,7 +129,7 @@ try {
break;
default:
throw new Error(
`Unknown source "${source}" (cbs, eurostat, fred, bis, worldbank, imf, owid, energycharts, rijksfinancien, ustreasury, usaspending, eponline, cbscw, cwintl, elections, parties, wikipolls, eurobarometer, whr, gtrends, gdelt, history, normalise-cbs)`
`Unknown source "${source}" (cbs, eurostat, fred, bis, oecd, statcan, worldbank, imf, owid, energycharts, rijksfinancien, ustreasury, usaspending, eponline, cbscw, cwintl, elections, parties, wikipolls, eurobarometer, whr, gtrends, gdelt, history, normalise-cbs)`
);
}
} finally {

View File

@@ -1,4 +1,5 @@
// A5 BIS central bank policy rates (WS_CBPOL), monthly and daily, in one call each.
// A5 BIS central bank policy rates (WS_CBPOL), monthly and daily, in one call each; plus the BIS
// long consumer price series (WS_LONG_CPI, index 2010 = 100, monthly) → `bis:cpi:<area>`.
import { getText, log, run, sql, writeSeries, type ObsRow, type SeriesRow } from './common';
const AREAS: Record<string, { name: string; region: string | null }> = {
@@ -7,7 +8,26 @@ const AREAS: Record<string, { name: string; region: string | null }> = {
KR: { name: 'Bank of Korea', region: 'KOR' },
CN: { name: "People's Bank of China", region: 'CHN' },
GB: { name: 'Bank of England', region: 'GBR' },
JP: { name: 'Bank of Japan', region: 'JPN' }
JP: { name: 'Bank of Japan', region: 'JPN' },
CA: { name: 'Bank of Canada', region: 'CAN' }
};
/** WS_LONG_CPI areas (no Curaçao: its CPI comes from CBS Curaçao). */
const CPI_AREAS: Record<string, string> = {
CA: 'CAN',
CN: 'CHN',
JP: 'JPN',
KR: 'KOR',
US: 'USA',
NL: 'NLD',
XM: 'EA',
DE: 'DEU',
FR: 'FRA',
BE: 'BEL',
GB: 'GBR',
IN: 'IND',
BR: 'BRA',
RU: 'RUS'
};
function parseCsv(text: string): Record<string, string>[] {
@@ -73,6 +93,35 @@ export async function bis(opts: { job: string }) {
log(`bis ${freq}: ${rows.length} rows`);
total += rows.length;
}
// Long consumer prices: unit 628 = index, 2010 = 100.
const cpiCsv = await getText(
`https://stats.bis.org/api/v2/data/dataflow/BIS/WS_LONG_CPI/1.0/M.${Object.keys(CPI_AREAS).join('+')}.628?format=csv`
);
const cpiObs: ObsRow[] = parseCsv(cpiCsv)
.filter((r) => r.REF_AREA in CPI_AREAS && /^\d{4}-\d{2}/.test(r.TIME_PERIOD ?? ''))
.filter((r) => r.OBS_VALUE !== '' && r.OBS_VALUE !== 'NaN')
.map((r) => ({
series_id: `bis:cpi:${r.REF_AREA}`,
period: `${r.TIME_PERIOD.slice(0, 7)}-01`,
value: Number(r.OBS_VALUE),
status: r.OBS_STATUS && r.OBS_STATUS !== 'A' ? r.OBS_STATUS : null
}));
const cpiSeries: SeriesRow[] = Object.entries(CPI_AREAS)
.filter(([code]) => cpiObs.some((o) => o.series_id === `bis:cpi:${code}`))
.map(([code, region]) => ({
series_id: `bis:cpi:${code}`,
source_id: 'bis',
source_code: `WS_LONG_CPI/M.${code}.628`,
title: 'Consumer prices (BIS long series)',
unit: 'Index 2010=100',
freq: 'M',
region_code: region,
theme: 'economy',
meta: { url: 'https://data.bis.org/topics/CPI' }
}));
await writeSeries(cpiSeries, cpiObs);
log(`bis cpi: ${cpiObs.length} obs`);
total += cpiObs.length;
return total;
});
}

View File

@@ -24,6 +24,7 @@ const hostGapMs: Record<string, number> = {
'fred.stlouisfed.org': 1000,
'ec.europa.eu': 500,
'stats.bis.org': 1000,
'www150.statcan.gc.ca': 1000,
'api.worldbank.org': 1000,
'www.imf.org': 1500,
'ourworldindata.org': 2000,
@@ -150,11 +151,28 @@ export const SOURCES = [
},
{
source_id: 'bis',
name: 'BIS central bank policy rates',
base_url: 'https://stats.bis.org/api/v2/data/dataflow/BIS/WS_CBPOL/1.0',
name: 'BIS (central bank policy rates, long consumer price series)',
base_url: 'https://stats.bis.org/api/v2/data/dataflow/BIS',
licence: 'Free reuse with attribution',
key_required: false,
attribution: 'Source: BIS, central bank policy rates (WS_CBPOL)'
attribution:
'Source: BIS, central bank policy rates (WS_CBPOL) and consumer prices (WS_LONG_CPI)'
},
{
source_id: 'oecd',
name: 'OECD Data Explorer (SDMX)',
base_url: 'https://sdmx.oecd.org/public/rest/data',
licence: 'CC BY 4.0',
key_required: false,
attribution: 'Source: OECD Data Explorer (prices; financial market statistics), CC BY 4.0'
},
{
source_id: 'statcan',
name: 'Statistics Canada (Web Data Service)',
base_url: 'https://www150.statcan.gc.ca/t1/wds/rest',
licence: 'Statistics Canada Open Licence',
key_required: false,
attribution: 'Source: Statistics Canada, table 18-10-0004-01 (Statistics Canada Open Licence)'
},
{
source_id: 'worldbank',
@@ -395,6 +413,7 @@ export const REGIONS: { region_code: string; name: string; kind: string; parent:
{ region_code: 'AUT', name: 'Austria', kind: 'country', parent: 'EU27' },
{ region_code: 'GBR', name: 'United Kingdom', kind: 'country', parent: 'WLD' },
{ region_code: 'JPN', name: 'Japan', kind: 'country', parent: 'WLD' },
{ region_code: 'CAN', name: 'Canada', 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' },
@@ -415,6 +434,7 @@ export const COMPARE_ISO3 = [
'KOR',
'CHN',
'CUW',
'CAN',
'DEU',
'FRA',
'BEL',

View File

@@ -87,7 +87,15 @@ export const EUROSTAT_DATASETS: Dataset[] = [
params: {},
geos: ['NL', 'DE', 'FR', 'IT', 'BE', 'ES', 'AT', 'EA']
},
{ code: 'prc_hicp_minr', theme: 'economy', params: { coicop18: 'TOTAL', unit: 'RCH_A' } },
{
// Rates for every geo; index levels (2015 = 100) and the main categories feed the CPI charts.
code: 'prc_hicp_minr',
theme: 'economy',
params: {
coicop18: ['TOTAL', 'CP01', 'CP04', 'NRG', 'TOT_X_NRG_FOOD'],
unit: ['RCH_A', 'I15']
}
},
{ code: 'prc_hicp_manr', theme: 'economy', params: { coicop: 'CP00', unit: 'RCH_A' } },
{ code: 'prc_hpi_q', theme: 'economy', params: { purchase: 'TOTAL', unit: ['I15_Q', 'RCH_A'] } },
{ code: 'road_eqr_carpda', theme: 'energy', params: { unit: 'NR' } },

View File

@@ -155,6 +155,21 @@ export const FRED_SERIES: Fred[] = [
region: 'USA',
theme: 'economy'
},
...(
[
['CPIUFDSL', 'food'],
['CPIHOSSL', 'housing'],
['CPIENGSL', 'energy'],
['CPILFESL', 'all items less food and energy']
] as const
).map(([id, what]) => ({
id,
title: `US consumer price index (CPI-U), ${what}`,
unit: 'Index 1982-84=100',
freq: 'M' as const,
region: 'USA',
theme: 'economy'
})),
{
id: 'UNRATE',
title: 'US unemployment rate',

129
scripts/stats/oecd.ts Normal file
View File

@@ -0,0 +1,129 @@
// OECD Data Explorer SDMX (no key, ≤20 requests a minute): consumer price indices by category
// (DF_PRICES_ALL, 2015 = 100) for the countries without a national feed here, and long-term
// (10-year) government bond yields (DF_FINMARK). Normalised only: `oecd:cpi:<ISO3>:<category>`
// and `oecd:irlt:<ISO3>`.
import { http, log, run, writeSeries, type ObsRow, type SeriesRow } from './common';
const BASE = 'https://sdmx.oecd.org/public/rest/data';
const CSV = { accept: 'application/vnd.sdmx.data+csv; charset=utf-8' };
/** CPI categories (OECD COICOP codes) → our short names. */
export const OECD_CPI_CATEGORIES: Record<string, { name: string; title: string }> = {
_T: { name: 'total', title: 'Consumer price index, all items' },
CP01: { name: 'food', title: 'Consumer price index, food and non-alcoholic beverages' },
CP04: {
name: 'housing',
title: 'Consumer price index, housing, water, electricity, gas and other fuels'
},
CP045_0722: { name: 'energy', title: 'Consumer price index, energy' },
_TXCP01_NRG: { name: 'core', title: 'Consumer price index, all items excluding food and energy' }
};
/** National CPI comes from CBS, BLS (FRED), StatCan and CBS Curaçao; OECD fills KOR, JPN, CHN. */
const CPI_AREAS = ['KOR', 'JPN', 'CHN'];
const IRLT_AREAS = [
'CAN',
'JPN',
'KOR',
'CHN',
'USA',
'NLD',
'DEU',
'FRA',
'GBR',
'BEL',
'IND',
'BRA',
'RUS',
'EA20'
];
const region = (a: string) => (a === 'EA20' ? 'EA' : a);
function parseCsv(text: string): Record<string, string>[] {
const lines = text.trim().split(/\r?\n/);
const head = lines[0].split(',');
return lines.slice(1).map((l) => Object.fromEntries(l.split(',').map((v, i) => [head[i], v])));
}
async function pull(
path: string,
key: (r: Record<string, string>) => { id: string; region: string; code: string } | null,
meta: (code: string) => { title: string; unit: string }
) {
const rows = parseCsv(await (await http(`${BASE}/${path}`, { headers: CSV })).text());
const series = new Map<string, SeriesRow>();
const obs: ObsRow[] = [];
for (const r of rows) {
const k = key(r);
if (!k || !/^\d{4}-\d{2}$/.test(r.TIME_PERIOD ?? '') || r.OBS_VALUE === '') continue;
const value = Number(r.OBS_VALUE);
if (!Number.isFinite(value)) continue;
if (!series.has(k.id)) {
const m = meta(k.code);
series.set(k.id, {
series_id: k.id,
source_id: 'oecd',
source_code: `${path.split('/')[0]}/${k.code}`,
title: m.title,
unit: m.unit,
freq: 'M',
region_code: k.region,
theme: 'economy',
meta: { url: 'https://data-explorer.oecd.org/' }
});
}
obs.push({
series_id: k.id,
period: `${r.TIME_PERIOD}-01`,
value,
status: r.OBS_STATUS || null
});
}
if (!obs.length) throw new Error(`no rows for ${path.split('?')[0]}`);
await writeSeries([...series.values()], obs);
return obs.length;
}
export async function oecd(opts: { only?: string; job: string }) {
await run('oecd', opts.job, async (note) => {
let total = 0;
const want = (j: string) => !opts.only || opts.only.split(',').includes(j);
if (want('cpi'))
try {
const n = await pull(
`OECD.SDD.TPS,DSD_PRICES@DF_PRICES_ALL,/${CPI_AREAS.join('+')}.M.N.CPI.IX.${Object.keys(OECD_CPI_CATEGORIES).join('+')}.N._Z?startPeriod=1950-01`,
(r) => {
const c = OECD_CPI_CATEGORIES[r.EXPENDITURE];
return c && r.UNIT_MEASURE === 'IX'
? { id: `oecd:cpi:${r.REF_AREA}:${c.name}`, region: r.REF_AREA, code: r.EXPENDITURE }
: null;
},
(code) => ({ title: OECD_CPI_CATEGORIES[code].title, unit: 'Index 2015=100' })
);
log(`oecd cpi: ${n} obs`);
total += n;
} catch (error) {
note(`cpi: ${error instanceof Error ? error.message : String(error)}`);
}
if (want('irlt'))
try {
const n = await pull(
`OECD.SDD.STES,DSD_STES@DF_FINMARK,/${IRLT_AREAS.join('+')}.M.IRLT......?startPeriod=1950-01`,
(r) =>
r.MEASURE === 'IRLT'
? {
id: `oecd:irlt:${region(r.REF_AREA)}`,
region: region(r.REF_AREA),
code: r.REF_AREA
}
: null,
() => ({ title: 'Long-term interest rate (10-year government bonds)', unit: '%' })
);
log(`oecd irlt: ${n} obs`);
total += n;
} catch (error) {
note(`irlt: ${error instanceof Error ? error.message : String(error)}`);
}
if (!total) throw new Error('nothing loaded');
return total;
});
}

57
scripts/stats/statcan.ts Normal file
View File

@@ -0,0 +1,57 @@
// Statistics Canada Web Data Service (no key): CPI table 18-10-0004-01 (2002 = 100, monthly,
// not seasonally adjusted) by vector. Normalised only: `statcan:cpi:<category>`.
import { getJson, log, run, writeSeries, type ObsRow, type SeriesRow } from './common';
export const STATCAN_CPI: { vector: number; name: string; title: string }[] = [
{ vector: 41690973, name: 'total', title: 'Canada consumer price index, all items' },
{ vector: 41690974, name: 'food', title: 'Canada consumer price index, food' },
{ vector: 41691050, name: 'housing', title: 'Canada consumer price index, shelter' },
{ vector: 41691239, name: 'energy', title: 'Canada consumer price index, energy' },
{
vector: 41691233,
name: 'core',
title: 'Canada consumer price index, all items excluding food and energy'
}
];
type Wds = {
status: string;
object?: { vectorId: number; vectorDataPoint: { refPer: string; value: number | null }[] };
}[];
export async function statcan(opts: { job: string }) {
await run('statcan', opts.job, async (note) => {
const ids = STATCAN_CPI.map((s) => `%22${s.vector}%22`).join(',');
const body = await getJson<Wds>(
`https://www150.statcan.gc.ca/t1/wds/rest/getDataFromVectorByReferencePeriodRange?vectorIds=${ids}&startRefPeriod=1900-01-01&endReferencePeriod=2100-12-01`
);
const series: SeriesRow[] = [];
const obs: ObsRow[] = [];
for (const s of STATCAN_CPI) {
const points = body.find((b) => b.object?.vectorId === s.vector)?.object?.vectorDataPoint;
if (!points?.length) {
note(`v${s.vector}: no data`);
continue;
}
const id = `statcan:cpi:${s.name}`;
series.push({
series_id: id,
source_id: 'statcan',
source_code: `18-10-0004-01/v${s.vector}`,
title: s.title,
unit: 'Index 2002=100',
freq: 'M',
region_code: 'CAN',
theme: 'economy',
meta: { url: 'https://www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=1810000401' }
});
for (const p of points)
if (p.value != null && /^\d{4}-\d{2}-01$/.test(p.refPer))
obs.push({ series_id: id, period: p.refPer, value: p.value, status: null });
}
if (!obs.length) throw new Error('no StatCan observations');
await writeSeries(series, obs);
log(`statcan: ${obs.length} obs in ${series.length} series`);
return obs.length;
});
}

View File

@@ -27,6 +27,7 @@ export const WB_INDICATORS: { id: string; title: string; unit: string; theme: st
{ 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: 'FP.CPI.TOTL', title: 'Consumer price index', unit: 'Index 2010=100', theme: 'economy' },
{ id: 'SP.POP.TOTL', title: 'Population', unit: 'people', theme: 'countries' },
{ id: 'SM.POP.NETM', title: 'Net migration', unit: 'people', theme: 'countries' },
{

View File

@@ -0,0 +1,96 @@
<script lang="ts">
// Country chips for the multi-country charts on /stats/economy: the core seven are on by
// default, the extra countries can be added, and the EU 27 comparison line can be toggled.
import { COUNTRY } from './regions.js';
import { FLAG } from './countries.js';
import { CORE7, EXTRA } from './econ.js';
let { selected = $bindable(), eu = $bindable() }: { selected: string[]; eu: boolean } = $props();
const toggle = (c: string) =>
(selected = selected.includes(c) ? selected.filter((x) => x !== c) : [...selected, c]);
const isCore = $derived(
selected.length === CORE7.length && CORE7.every((c) => selected.includes(c))
);
</script>
<div class="picker" role="group" aria-label="Countries on the charts">
<div class="row">
<span class="label">core</span>
{#each CORE7 as c (c)}
<button
type="button"
class="chip"
aria-pressed={selected.includes(c)}
onclick={() => toggle(c)}
>
<span aria-hidden="true">{FLAG[c]}</span>{COUNTRY[c]}
</button>
{/each}
<button type="button" class="chip eu" aria-pressed={eu} onclick={() => (eu = !eu)}>
<span aria-hidden="true">🇪🇺</span>EU 27 line
</button>
</div>
<div class="row">
<span class="label">add</span>
{#each EXTRA as c (c)}
<button
type="button"
class="chip"
aria-pressed={selected.includes(c)}
onclick={() => toggle(c)}
>
<span aria-hidden="true">{FLAG[c]}</span>{COUNTRY[c]}
</button>
{/each}
{#if !isCore}
<button type="button" class="reset" onclick={() => (selected = [...CORE7])}>reset</button>
{/if}
</div>
</div>
<style>
.picker {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0 0 1rem;
padding: 0.75rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-bg);
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}
.label {
width: 2.6rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
}
.chip,
.reset {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
background: transparent;
color: var(--site-muted);
cursor: pointer;
}
.chip[aria-pressed='true'] {
color: var(--site-text);
border-color: var(--site-text);
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.reset {
border-style: dashed;
}
</style>

View File

@@ -13,6 +13,7 @@ export const COUNTRY_PAGES = [
'BEL',
'GBR',
'JPN',
'CAN',
'IND',
'BRA',
'RUS',
@@ -34,6 +35,7 @@ export const FLAG: Record<string, string> = {
BEL: '🇧🇪',
GBR: '🇬🇧',
JPN: '🇯🇵',
CAN: '🇨🇦',
IND: '🇮🇳',
BRA: '🇧🇷',
RUS: '🇷🇺',
@@ -52,6 +54,7 @@ export const COUNTRY_COLOR: Record<string, string> = {
BEL: '#a16207',
GBR: '#a855f7',
JPN: '#ec4899',
CAN: '#dc2626',
IND: '#10b981',
BRA: '#84cc16',
RUS: '#0d9488',

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { CORE7, CPI_BY_CATEGORY, CPI_TOTAL, rebase, yearMean, yoyByDate } from './econ.js';
describe('econ helpers', () => {
const pts: [string, number][] = [
['2014-12-01', 99],
['2015-01-01', 100],
['2015-07-01', 102],
['2016-01-01', 103],
['2016-07-01', 104.04]
];
it('averages a calendar year', () => expect(yearMean(pts, 2015)).toBe(101));
it('rebases to the base-year mean', () =>
expect(rebase(pts, 2015)?.[3]).toEqual(['2016-01-01', 101.98]));
it('returns null when the base year is missing', () => expect(rebase(pts, 2010)).toBeNull());
it('computes year on year by date and skips gaps', () =>
expect(yoyByDate(pts)).toEqual([
['2016-01-01', 3],
['2016-07-01', 2]
]));
it('covers every core country for CPI', () => {
for (const c of CORE7) {
expect(CPI_TOTAL[c]).toBeDefined();
expect(CPI_BY_CATEGORY[c]?.ids.total).toBeDefined();
}
});
});

151
src/lib/stats/econ.ts Normal file
View File

@@ -0,0 +1,151 @@
// Country sets and series maps for /stats/economy. The core set (CAN, KOR, CHN, JPN, USA, NLD,
// CUW) is the default on every multi-country chart; the extra countries can be added, and the
// EU aggregate is kept as a comparison line. Every id points at a stored series; nothing here is
// estimated, gaps stay gaps.
import type { StatPoint } from './chart.js';
export const CORE7 = ['CAN', 'KOR', 'CHN', 'JPN', 'USA', 'NLD', 'CUW'] as const;
export const EXTRA = ['DEU', 'FRA', 'GBR', 'BEL', 'IND', 'BRA', 'RUS'] as const;
export const ECON_COUNTRIES = [...CORE7, ...EXTRA] as const;
export type EconCountry = (typeof ECON_COUNTRIES)[number];
/** Comparison aggregates: EU 27 by default; the euro area where the data is per currency area. */
export type Aggregate = 'EU27' | 'EA';
/** All-items CPI, index level, best monthly source per region (national office first). */
export const CPI_TOTAL: Record<string, { id: string; source: string; base: string }> = {
// CBS CPI as carried by the BIS long series (1965→). CBS 83131NED (2015 = 100) stops in
// December 2025 and 86141NED (2025 = 100) starts in December 2009, so neither covers the span.
NLD: { id: 'bis:cpi:NL', source: 'bis', base: '2010 = 100' },
USA: { id: 'fred:CPIAUCSL', source: 'fred', base: '1982–84 = 100' },
CAN: { id: 'statcan:cpi:total', source: 'statcan', base: '2002 = 100' },
JPN: { id: 'bis:cpi:JP', source: 'bis', base: '2010 = 100' },
KOR: { id: 'bis:cpi:KR', source: 'bis', base: '2010 = 100' },
CHN: { id: 'bis:cpi:CN', source: 'bis', base: '2010 = 100' },
CUW: { id: 'cw:cpi:index', source: 'cbscw', base: '2023 = 100' },
DEU: { id: 'bis:cpi:DE', source: 'bis', base: '2010 = 100' },
FRA: { id: 'bis:cpi:FR', source: 'bis', base: '2010 = 100' },
GBR: { id: 'bis:cpi:GB', source: 'bis', base: '2010 = 100' },
BEL: { id: 'bis:cpi:BE', source: 'bis', base: '2010 = 100' },
IND: { id: 'bis:cpi:IN', source: 'bis', base: '2010 = 100' },
BRA: { id: 'bis:cpi:BR', source: 'bis', base: '2010 = 100' },
RUS: { id: 'bis:cpi:RU', source: 'bis', base: '2010 = 100' },
EU27: {
id: 'eurostat:prc_hicp_minr:I15.TOTAL.EU27_2020',
source: 'eurostat',
base: '2015 = 100'
},
EA: { id: 'eurostat:prc_hicp_minr:I15.TOTAL.EA20', source: 'eurostat', base: '2015 = 100' }
};
export const CPI_CATS = ['total', 'food', 'housing', 'energy', 'core'] as const;
export type CpiCat = (typeof CPI_CATS)[number];
export const CPI_CAT_LABEL: Record<CpiCat, string> = {
total: 'All items',
food: 'Food',
housing: 'Housing',
energy: 'Energy',
core: 'Core (excl. food and energy)'
};
const hicp = (g: string): Record<CpiCat, string> => ({
total: `eurostat:prc_hicp_minr:I15.TOTAL.${g}`,
food: `eurostat:prc_hicp_minr:I15.CP01.${g}`,
housing: `eurostat:prc_hicp_minr:I15.CP04.${g}`,
energy: `eurostat:prc_hicp_minr:I15.NRG.${g}`,
core: `eurostat:prc_hicp_minr:I15.TOT_X_NRG_FOOD.${g}`
});
const oecd = (c: string, cats: CpiCat[]) =>
Object.fromEntries(cats.map((k) => [k, `oecd:cpi:${c}:${k}`])) as Partial<Record<CpiCat, string>>;
/** CPI by main category where the source publishes it. */
export const CPI_BY_CATEGORY: Record<
string,
{ ids: Partial<Record<CpiCat, string>>; source: string; note: string }
> = {
NLD: { ids: hicp('NL'), source: 'eurostat', note: 'HICP (Eurostat), 2015 = 100' },
USA: {
ids: {
total: 'fred:CPIAUCSL',
food: 'fred:CPIUFDSL',
housing: 'fred:CPIHOSSL',
energy: 'fred:CPIENGSL',
core: 'fred:CPILFESL'
},
source: 'fred',
note: 'BLS CPI-U, seasonally adjusted, via FRED'
},
CAN: {
ids: Object.fromEntries(CPI_CATS.map((k) => [k, `statcan:cpi:${k}`])),
source: 'statcan',
note: 'Statistics Canada 18-10-0004-01; housing = shelter'
},
KOR: {
ids: oecd('KOR', [...CPI_CATS]),
source: 'oecd',
note: 'OECD prices database'
},
JPN: {
ids: oecd('JPN', [...CPI_CATS]),
source: 'oecd',
note: 'OECD prices database; the OECD series for Japan stop in June 2021'
},
CHN: {
ids: oecd('CHN', ['total', 'food']),
source: 'oecd',
note: 'OECD prices database: all items and food only (other NBS categories are manual)'
},
CUW: {
ids: { total: 'cw:cpi:index' },
source: 'cbscw',
note: 'CBS Curaçao: all items only'
},
EU27: { ids: hicp('EU27_2020'), source: 'eurostat', note: 'HICP (Eurostat), 2015 = 100' },
EA: { ids: hicp('EA20'), source: 'eurostat', note: 'HICP (Eurostat), 2015 = 100' }
};
/** BIS policy rate area per country (euro members → ECB); missing = no BIS series. */
export const POLICY_RATE: Record<string, string> = {
CAN: 'CA',
KOR: 'KR',
CHN: 'CN',
JPN: 'JP',
USA: 'US',
NLD: 'XM',
GBR: 'GB',
EA: 'XM'
};
/** Annual macro indicators: IMF WEO first, then CBS Curaçao for Curaçao, then the World Bank. */
export const ANNUAL = {
growth: { imf: 'NGDP_RPCH', wb: 'NY.GDP.MKTP.KD.ZG', cw: 'cw:gdp:real_growth_pct' },
unemployment: {
imf: 'LUR',
wb: 'SL.UEM.TOTL.ZS',
cw: 'cw:lfs:unemployment_rate_pct:total'
},
debt: { imf: 'GGXWDG_NGDP', wb: 'GC.DOD.TOTL.GD.ZS', cw: null },
trade: { imf: null, wb: 'NE.TRD.GNFS.ZS', cw: null }
} as const;
/** Mean of the points in a calendar year, or null when the year has no observations. */
export function yearMean(points: StatPoint[], year: number): number | null {
const vals = points.filter(([d]) => d.startsWith(`${year}-`)).map(([, v]) => v);
return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
}
/** Rebase an index to (base year average) = 100. Null when the base year is not covered. */
export function rebase(points: StatPoint[], year: number): StatPoint[] | null {
const base = yearMean(points, year);
if (!base) return null;
return points.map(([d, v]) => [d, Math.round((v / base) * 10000) / 100]);
}
/** Year-on-year % change by date (same month a year earlier); missing months stay missing. */
export function yoyByDate(points: StatPoint[]): StatPoint[] {
const map = new Map(points);
const out: StatPoint[] = [];
for (const [d, v] of points) {
const prev = map.get(`${Number(d.slice(0, 4)) - 1}${d.slice(4)}`);
if (prev) out.push([d, Math.round(((v - prev) / prev) * 10000) / 100]);
}
return out;
}

View File

@@ -19,6 +19,7 @@ export const COUNTRY: Record<string, string> = {
CN: 'China',
GB: 'United Kingdom',
JP: 'Japan',
CA: 'Canada',
NLD: 'Netherlands',
USA: 'United States',
KOR: 'South Korea',
@@ -31,6 +32,7 @@ export const COUNTRY: Record<string, string> = {
BEL: 'Belgium',
GBR: 'United Kingdom',
JPN: 'Japan',
CAN: 'Canada',
IND: 'India',
BRA: 'Brazil',
RUS: 'Russia',

View File

@@ -1,30 +1,49 @@
import { cached, loadSeries } from '#lib/server/stats.js';
import {
ANNUAL,
CPI_BY_CATEGORY,
CPI_TOTAL,
ECON_COUNTRIES,
POLICY_RATE
} from '#lib/stats/econ.js';
import type { PageServerLoad } from './$types';
const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020', 'EA20'];
const YIELDS = ['NL', 'DE', 'FR', 'IT', 'ES', 'BE', 'AT', 'EA'];
const REGIONS = [...ECON_COUNTRIES, 'EU27', 'EA'];
export const load: PageServerLoad = async () => ({
series: await cached('stats:economy', () =>
loadSeries([
...['US', 'XM', 'KR', 'CN', 'GB', 'JP'].map((a) => ({
...[...new Set(Object.values(POLICY_RATE))].map((a) => ({
id: `bis:M:${a}`,
from: '1990-01-01'
})),
...EU.map((g) => `eurostat:prc_hicp_minr:RCH_A.TOTAL.${g}`),
{ id: 'fred:CPIAUCSL', from: '1989-01-01' },
// CPI index levels (all items, then the categories)
...[
...new Set([
...Object.values(CPI_TOTAL).map((c) => c.id),
...Object.values(CPI_BY_CATEGORY).flatMap((c) => Object.values(c.ids))
])
].map((id) => ({ id, from: '1960-01-01' })),
...REGIONS.map((c) => ({ id: `oecd:irlt:${c}`, from: '1980-01-01' })),
...YIELDS.map((g) => `eurostat:irt_lt_mcby_m:MCBY.${g}`),
{ id: 'fred:DGS10', from: '1990-01-01', resample: 'M' },
{ id: 'fred:DGS2', from: '1990-01-01', resample: 'M' },
'fred:IRLTLT01KRM156N',
...['NL', 'KR', 'US', 'CN'].map((c) => ({
id: `fred:SPASTT01${c}M661N`,
from: '1990-01-01'
})),
{ id: 'fred:SP500', resample: 'W' },
{ id: 'fred:NASDAQCOM', from: '1990-01-01', resample: 'W' },
...EU.map((g) => `eurostat:nama_10_gdp:CLV_PCH_PRE.B1GQ.${g}`),
...EU.map((g) => `eurostat:nama_10_gdp:CP_MEUR.B1GQ.${g}`),
...Object.values(ANNUAL).flatMap((ind) =>
REGIONS.flatMap((c) => [
...(ind.imf ? [{ id: `imf:${ind.imf}:${c}`, from: '1980-01-01' }] : []),
{ id: `wb:${ind.wb}:${c}`, from: '1980-01-01' }
])
),
...Object.values(ANNUAL).flatMap((ind) => (ind.cw ? [ind.cw] : [])),
'fred:GDPC1',
'fred:UNRATE',
...EU.flatMap((g) => [

View File

@@ -1,25 +1,159 @@
<script lang="ts">
import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte';
import { barRankOption, latest, lineOption, spread, yoy } from '#lib/stats/chart.js';
import CountryPicker from '#lib/stats/CountryPicker.svelte';
import * as Tabs from '#lib/components/ui/tabs/index.js';
import { barRankOption, latest, lineOption, spread, type Line } from '#lib/stats/chart.js';
import { num, period } from '#lib/stats/format.js';
import { COUNTRY } from '#lib/stats/regions.js';
import { COUNTRY_COLOR } from '#lib/stats/countries.js';
import {
ANNUAL,
CORE7,
CPI_BY_CATEGORY,
CPI_CATS,
CPI_CAT_LABEL,
CPI_TOTAL,
POLICY_RATE,
rebase,
yoyByDate,
type CpiCat
} from '#lib/stats/econ.js';
let { data } = $props();
const s = $derived(data.series);
const a = $derived(data.attributions);
const pts = (id: string) => s[id]?.points ?? [];
const RATES = [
['US', 'Fed', '#3b82f6'],
['XM', 'ECB', '#eab308'],
['KR', 'Bank of Korea', '#e11d48'],
['GB', 'Bank of England', '#a855f7'],
['JP', 'Bank of Japan', '#10b981'],
['CN', 'PBoC', '#f97316']
] as const;
const usCpi = $derived(yoy(pts('fred:CPIAUCSL')).filter(([d]) => d >= '1997-01-01'));
const hicp = (g: string) => pts(`eurostat:prc_hicp_minr:RCH_A.TOTAL.${g}`);
// Country selection shared by every multi-country chart.
let selected = $state<string[]>([...CORE7]);
let eu = $state(true);
const regions = $derived([...selected, ...(eu ? ['EU27'] : [])]);
const nameOf = (c: string) =>
c === 'EU27' ? 'EU 27' : c === 'EA' ? 'Euro area' : (COUNTRY[c] ?? c);
const colorOf = (c: string) => COUNTRY_COLOR[c] ?? '#64748b';
const missing = (list: string[]) =>
list.length ? `No data for ${list.map(nameOf).join(', ')}.` : '';
// CPI: index level rebased to a common year, or year-on-year %.
let cpiMode = $state<'level' | 'yoy'>('level');
let baseYear = $state(2015);
const BASE_YEARS = [2000, 2010, 2015, 2020, 2025];
const cpiLine = (id: string): [string, number][] | null => {
const p = pts(id);
if (!p.length) return null;
return cpiMode === 'yoy' ? yoyByDate(p) : rebase(p, baseYear);
};
const cpiLines = $derived.by(() => {
const lines: Line[] = [];
const gaps: string[] = [];
for (const c of regions) {
const src = CPI_TOTAL[c];
const points = src ? cpiLine(src.id) : null;
if (!points?.length) gaps.push(c);
else lines.push({ name: nameOf(c), points, color: colorOf(c), dashed: c === 'EU27' });
}
return { lines, gaps };
});
const cpiSources = $derived([
...new Set(regions.map((c) => CPI_TOTAL[c]?.source).filter(Boolean))
] as string[]);
let catCountry = $state('NLD');
const CAT_COLORS: Record<CpiCat, string> = {
total: '#64748b',
food: '#10b981',
housing: '#a855f7',
energy: '#f97316',
core: '#3b82f6'
};
const catLines = $derived.by(() => {
const def = CPI_BY_CATEGORY[catCountry];
return CPI_CATS.flatMap((k) => {
const id = def?.ids[k];
const points = id ? cpiLine(id) : null;
return points?.length
? [{ name: CPI_CAT_LABEL[k], points, color: CAT_COLORS[k], dashed: k === 'total' }]
: [];
});
});
const catMissing = $derived(
CPI_CATS.filter((k) => !CPI_BY_CATEGORY[catCountry]?.ids[k]).map((k) =>
CPI_CAT_LABEL[k].toLowerCase()
)
);
// Policy rates (BIS), euro members follow the ECB.
const rateLines = $derived.by(() => {
const seen: string[] = [];
const lines: Line[] = [];
const gaps: string[] = [];
for (const c of [...selected, ...(eu ? ['EA'] : [])]) {
const area = POLICY_RATE[c] ?? (['DEU', 'FRA', 'BEL'].includes(c) ? 'XM' : undefined);
const p = area ? pts(`bis:M:${area}`) : [];
if (!p.length) gaps.push(c);
else if (!seen.includes(area!)) {
seen.push(area!);
lines.push({
name: area === 'XM' ? 'ECB (euro area)' : nameOf(c),
points: p,
color: area === 'XM' ? '#1d4ed8' : colorOf(c),
kind: 'step'
});
}
}
return { lines, gaps };
});
const yieldLines = $derived.by(() => {
const lines: Line[] = [];
const gaps: string[] = [];
for (const c of [...selected, ...(eu ? ['EA'] : [])]) {
const p = pts(`oecd:irlt:${c}`);
if (!p.length) gaps.push(c);
else lines.push({ name: nameOf(c), points: p, color: colorOf(c), dashed: c === 'EA' });
}
return { lines, gaps };
});
// Annual indicators: IMF WEO (outcomes only, forecasts cut), World Bank where the IMF has none.
const annual = (key: keyof typeof ANNUAL) => {
const ind = ANNUAL[key];
const lines: Line[] = [];
const gaps: string[] = [];
const used: string[] = [];
for (const c of regions) {
const imf = ind.imf ? s[`imf:${ind.imf}:${c}`] : undefined;
const wb = s[`wb:${ind.wb}:${c}`];
const cw = c === 'CUW' && ind.cw ? s[ind.cw] : undefined;
const pick = imf?.points.length
? imf
: cw?.points.length
? cw
: wb?.points.length
? wb
: undefined;
if (!pick) {
gaps.push(c);
continue;
}
if (!used.includes(pick.sourceId)) used.push(pick.sourceId);
const cut = pick.forecastFrom ? `${pick.forecastFrom}-01-01` : '9999';
lines.push({
name: nameOf(c),
points: pick.points.filter(([d]) => d < cut),
color: colorOf(c),
dashed: c === 'EU27'
});
}
return { lines, gaps, sources: used };
};
const growth = $derived(annual('growth'));
const unemployment = $derived(annual('unemployment'));
const govDebt = $derived(annual('debt'));
const trade = $derived(annual('trade'));
// Europe-only charts (Eurostat).
const y = (g: string) => pts(`eurostat:irt_lt_mcby_m:MCBY.${g}`);
const frde = $derived(spread(y('FR'), y('DE')));
const itde = $derived(spread(y('IT'), y('DE')));
@@ -53,19 +187,19 @@
const k = (id: string) => lastOf(id);
const fed = $derived(k('bis:M:US'));
const ecb = $derived(k('bis:M:XM'));
const bok = $derived(k('bis:M:KR'));
const boc = $derived(k('bis:M:CA'));
const boj = $derived(k('bis:M:JP'));
const nlInfl = $derived(k('eurostat:prc_hicp_minr:RCH_A.TOTAL.NL'));
const eaInfl = $derived(k('eurostat:prc_hicp_minr:RCH_A.TOTAL.EA20'));
const usInfl = $derived(latest(usCpi));
const spreadNow = $derived(latest(frde));
const us10Now = $derived(latest(us10));
const usInfl = $derived(latest(yoyByDate(pts('fred:CPIAUCSL'))));
const caInfl = $derived(latest(yoyByDate(pts('statcan:cpi:total'))));
</script>
<svelte:head>
<title>economy · stats · Stan Runge</title>
<meta
name="description"
content="Interest rates, inflation, bond spreads, stock indices, GDP, public debt and migration: the Netherlands, Europe, the US and Korea."
content="Consumer prices (index levels and inflation by category), interest rates, bond yields, growth, unemployment, debt and trade for Canada, South Korea, China, Japan, the US, the Netherlands and Curaçao, with the EU as a comparison."
/>
</svelte:head>
@@ -73,9 +207,10 @@
<span class="stats-eyebrow">stats / economy</span>
<h1>Economy</h1>
<p>
Central bank rates, inflation, 10-year bond yields and spreads, stock markets, growth, public
finances and migration. The Netherlands next to Europe, the US and South Korea, refreshed daily
from open sources.
Consumer prices, central bank rates, bond yields, growth, jobs, debt and trade for Canada, South
Korea, China, Japan, the US, the Netherlands and Curaçao, with the EU as a comparison line. Add
other countries with the picker. Refreshed from open sources (CBS, BLS, Statistics Canada, CBS
Curaçao, BIS, OECD, IMF, World Bank, Eurostat).
</p>
</section>
@@ -95,11 +230,18 @@
color="#eab308"
/>
<Kpi
label="Bank of Korea"
value={num(bok?.[1], 2)}
label="Bank of Canada"
value={num(boc?.[1], 2)}
unit="%"
sub={period(bok?.[0])}
color="#e11d48"
sub={period(boc?.[0])}
color="#dc2626"
/>
<Kpi
label="Bank of Japan"
value={num(boj?.[1], 2)}
unit="%"
sub={period(boj?.[0])}
color="#ec4899"
/>
<Kpi
label="NL inflation (HICP)"
@@ -123,76 +265,127 @@
color="#06b6d4"
/>
<Kpi
label="France–Germany 10y spread"
value={num(spreadNow ? spreadNow[1] * 100 : null, 0)}
unit="bp"
sub={period(spreadNow?.[0])}
color="#a855f7"
/>
<Kpi
label="US 10-year yield"
value={num(us10Now?.[1], 2)}
label="Canada inflation (CPI)"
value={num(caInfl?.[1])}
unit="%"
sub={period(us10Now?.[0])}
color="#64748b"
sub={period(caInfl?.[0])}
color="#dc2626"
/>
</div>
<h2 class="stats-section-title">Rates and inflation</h2>
<CountryPicker bind:selected bind:eu />
<h2 class="stats-section-title">Prices and rates</h2>
<div class="stats-grid">
<StatChart
title="Consumer prices"
subtitle={cpiMode === 'level'
? `index, ${baseYear} average = 100 · monthly`
: '% change on the same month a year earlier'}
sources={cpiSources}
attributions={a}
height="400px"
wide
note={`National CPI for the US (BLS CPI-U), Canada (Statistics Canada) and Curaçao (CBS Curaçao); BIS long series (national CPIs) for the Netherlands, Japan, South Korea, China and the added countries; EU 27 is the HICP. Each source keeps its own base year; the chart divides by the ${baseYear} average.${cpiMode === 'level' ? ' Series that do not cover the base year are left out.' : ''} ${missing(cpiLines.gaps)}`}
option={(t) =>
lineOption(t, cpiLines.lines, { unit: cpiMode === 'level' ? 'index' : '%', zoom: true })}
>
<div class="controls">
<Tabs.Root bind:value={cpiMode}>
<Tabs.List>
<Tabs.Trigger value="level">index level</Tabs.Trigger>
<Tabs.Trigger value="yoy">year on year %</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
{#if cpiMode === 'level'}
<label class="select">
base year
<select bind:value={baseYear}>
{#each BASE_YEARS as yr (yr)}<option value={yr}>{yr}</option>{/each}
</select>
</label>
{/if}
</div>
</StatChart>
<StatChart
title={`Consumer prices by category: ${nameOf(catCountry)}`}
subtitle={cpiMode === 'level'
? `index, ${baseYear} average = 100 · monthly`
: '% change on the same month a year earlier'}
sources={[CPI_BY_CATEGORY[catCountry]?.source ?? 'eurostat']}
attributions={a}
height="380px"
wide
note={`${CPI_BY_CATEGORY[catCountry]?.note ?? ''}.${catMissing.length ? ` Not published here: ${catMissing.join(', ')}.` : ''} Category definitions differ a little per source (Canada's housing is shelter; the HICP and OECD use COICOP 04, housing including utilities).`}
option={(t) =>
lineOption(t, catLines, { unit: cpiMode === 'level' ? 'index' : '%', zoom: true })}
>
<div class="controls">
<label class="select">
country
<select bind:value={catCountry}>
{#each Object.keys(CPI_BY_CATEGORY) as c (c)}<option value={c}>{nameOf(c)}</option>{/each}
</select>
</label>
<span class="hint">uses the index / year-on-year switch and base year above</span>
</div>
</StatChart>
<StatChart
title="Central bank policy rates"
subtitle="% · monthly"
subtitle="% · monthly · BIS"
sources={['bis']}
attributions={a}
option={(t) =>
lineOption(
t,
RATES.map(([c, name, color]) => ({ name, color, kind: 'step', points: pts(`bis:M:${c}`) })),
{ unit: '%', zoom: true }
)}
note={`Euro members follow the ECB. ${missing(rateLines.gaps)}${rateLines.gaps.includes('CUW') ? ' Curaçao: the CBCS pegs the guilder to the US dollar and is not in the BIS policy rate set.' : ''}`}
option={(t) => lineOption(t, rateLines.lines, { unit: '%', zoom: true })}
/>
<StatChart
title="Inflation"
subtitle="% year on year · HICP (Europe), CPI-U (US)"
sources={['eurostat', 'fred']}
title="10-year government bond yields"
subtitle="% · monthly · OECD long-term interest rates"
sources={['oecd']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Netherlands', points: hicp('NL'), color: '#f97316' },
{ name: 'Euro area', points: hicp('EA20'), color: '#10b981' },
{ name: 'Germany', points: hicp('DE'), color: '#64748b' },
{ name: 'United States', points: usCpi, color: '#3b82f6' }
],
{ unit: '%', zoom: true }
)}
note={missing(yieldLines.gaps)}
option={(t) => lineOption(t, yieldLines.lines, { unit: '%', zoom: true })}
/>
</div>
<h2 class="stats-section-title">Bond markets</h2>
<h2 class="stats-section-title">Growth, jobs and debt</h2>
<div class="stats-grid">
<StatChart
title="10-year government bond yields"
subtitle="% · monthly (EMU convergence criterion yields; US Treasury, Korea via OECD)"
sources={['eurostat', 'fred']}
title="Real GDP growth"
subtitle="% change on the previous year · outcomes only, no forecasts"
sources={growth.sources}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Netherlands', points: y('NL'), color: '#f97316' },
{ name: 'Germany', points: y('DE'), color: '#64748b' },
{ name: 'France', points: y('FR'), color: '#3b82f6' },
{ name: 'Italy', points: y('IT'), color: '#10b981' },
{ name: 'Spain', points: y('ES'), color: '#eab308' },
{ name: 'United States', points: us10, color: '#a855f7' },
{ name: 'South Korea', points: pts('fred:IRLTLT01KRM156N'), color: '#e11d48' }
],
{ unit: '%', zoom: true }
)}
note={`IMF World Economic Outlook; CBS Curaçao (real growth, 2011→) for Curaçao. ${missing(growth.gaps)}`}
option={(t) => lineOption(t, growth.lines, { unit: '%', zoom: true })}
/>
<StatChart
title="Unemployment rate"
subtitle="% of the labour force · annual"
sources={unemployment.sources}
attributions={a}
note={`IMF WEO; CBS Curaçao labour force survey (2014→) for Curaçao. ${missing(unemployment.gaps)}`}
option={(t) => lineOption(t, unemployment.lines, { unit: '%', zoom: true })}
/>
<StatChart
title="Government debt"
subtitle="general government gross debt, % of GDP · annual"
sources={govDebt.sources}
attributions={a}
note={`IMF WEO; World Bank central government debt where the IMF has none. ${missing(govDebt.gaps)}`}
option={(t) => lineOption(t, govDebt.lines, { unit: '% of GDP', zoom: true })}
/>
<StatChart
title="Trade openness"
subtitle="exports + imports of goods and services, % of GDP · annual"
sources={trade.sources}
attributions={a}
note={missing(trade.gaps)}
option={(t) => lineOption(t, trade.lines, { unit: '% of GDP', zoom: true })}
/>
</div>
<h2 class="stats-section-title">Bond and stock markets</h2>
<div class="stats-grid">
<StatChart
title="Spreads over Germany"
subtitle="percentage points · 10-year yield minus the German Bund"
@@ -256,37 +449,8 @@
/>
</div>
<h2 class="stats-section-title">Growth and jobs</h2>
<h2 class="stats-section-title">United States</h2>
<div class="stats-grid">
<StatChart
title="Real GDP growth"
subtitle="% change on the previous year"
sources={['eurostat']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Netherlands',
points: pts('eurostat:nama_10_gdp:CLV_PCH_PRE.B1GQ.NL'),
color: '#f97316',
kind: 'bar'
},
{
name: 'EU 27',
points: pts('eurostat:nama_10_gdp:CLV_PCH_PRE.B1GQ.EU27_2020'),
color: '#3b82f6'
},
{
name: 'Germany',
points: pts('eurostat:nama_10_gdp:CLV_PCH_PRE.B1GQ.DE'),
color: '#64748b'
}
],
{ unit: '%' }
)}
/>
<StatChart
title="US real GDP and unemployment"
subtitle="billions of chained 2017 USD (SAAR) · unemployment %"
@@ -304,7 +468,7 @@
/>
</div>
<h2 class="stats-section-title">Public finances and migration</h2>
<h2 class="stats-section-title">Europe: public finances and migration</h2>
<div class="stats-grid">
<StatChart
title="Government debt"
@@ -401,6 +565,34 @@
/>
</div>
<p class="stats-note">
Policy rates: BIS central bank policy rates (month end). Yields for the euro countries are
Eurostat's EMU convergence-criterion series.
Policy rates: BIS central bank policy rates (month end). Spreads use Eurostat's EMU
convergence-criterion yields. The country picker drives the charts in the first two sections; the
European and US sections keep their own fixed sets.
</p>
<style>
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.select {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--site-muted);
}
.select select {
border: 1px solid var(--site-border);
border-radius: 0.5rem;
background: var(--site-bg);
color: var(--site-text);
padding: 0.2rem 0.4rem;
}
.hint {
font-size: 0.75rem;
color: var(--site-muted);
}
</style>