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

@@ -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>