mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(feeds): ECB CISS European financial stress seeder + GetEuFsi RPC (#2278) - New seed-fsi-eu.mjs fetches ECB CISS (0-1 systemic stress index for Euro area) via SDMX-JSON REST API (free, no auth); TTL=604800s (7d, weekly data cadence) - New GetEuFsi RPC in EconomicService with proto + handler; cache tier: slow - FSIPanel now shows EU CISS gauge below US FSI with label thresholds: Low<0.2, Moderate<0.4, Elevated<0.6, High>=0.6 - Registered economic:fsi-eu:v1 in health.js BOOTSTRAP_KEYS + SEED_META, bootstrap.js, cache-keys.ts BOOTSTRAP_TIERS; hydrated via getHydratedData('euFsi') - All 2348 test:data tests pass; typecheck + typecheck:api clean * fix(ecb-ciss): address code review findings on PR #2334 - Raise FSI_EU_TTL from 604800s (7d) to 864000s (10d) to match other weekly seeds (bigmac, groceryBasket, fuelPrices) and provide a 3-day buffer against cron-drift or missed Saturday runs - Format latestDate via toLocaleDateString() in FSIPanel CISS section instead of displaying the raw ISO string (e.g. "2025-04-04") * fix(ecb-ciss): address Greptile review comments on PR #2334 - Fix misleading "Daily frequency" comment in seed-fsi-eu.mjs (SDMX uses 'D' series key but only Friday/weekly observations are present) - Replace latestValue > 0 guards with Number.isFinite() in FSIPanel.ts so a valid CISS reading of exactly 0 is not incorrectly excluded * chore: regenerate proto outputs after rebase
105 lines
3.8 KiB
JavaScript
105 lines
3.8 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
|
||
loadEnvFile(import.meta.url);
|
||
|
||
// ECB SDMX REST API — free, no auth required.
|
||
// CISS: Composite Indicator of Systemic Stress (0–1 range, higher = more systemic stress).
|
||
// Weekly frequency, Euro area aggregate; ECB publishes each Friday (SDMX series key uses 'D' but only Friday observations are present).
|
||
const ECB_CISS_URL =
|
||
'https://data-api.ecb.europa.eu/service/data/CISS/D.U2.Z0Z.4F.EC.SS_CI.IDX?format=jsondata&lastNObservations=52';
|
||
|
||
const FSI_EU_KEY = 'economic:fsi-eu:v1';
|
||
// Weekly cron (Saturday) — 864000s (10 days) matches other weekly seeds (bigmac, groceryBasket,
|
||
// fuelPrices) and provides a 3-day buffer against cron-drift or missed runs.
|
||
const FSI_EU_TTL = 864000;
|
||
|
||
function classifyLabel(value) {
|
||
if (value < 0.2) return 'Low';
|
||
if (value < 0.4) return 'Moderate';
|
||
if (value < 0.6) return 'Elevated';
|
||
return 'High';
|
||
}
|
||
|
||
async function fetchEcbCiss() {
|
||
const resp = await fetch(ECB_CISS_URL, {
|
||
headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' },
|
||
signal: AbortSignal.timeout(15_000),
|
||
});
|
||
if (!resp.ok) throw new Error(`ECB CISS API: HTTP ${resp.status}`);
|
||
|
||
const json = await resp.json();
|
||
|
||
// SDMX-JSON structure:
|
||
// dataSets[0].series["0:0:0:0:0:0:0"].observations = { "0": [value,...], "1": [...], ... }
|
||
// structure.dimensions.observation[0].values = [{ id: "2025-04-04", ... }, ...]
|
||
const series = json?.dataSets?.[0]?.series?.['0:0:0:0:0:0:0'];
|
||
if (!series) throw new Error('ECB CISS: unexpected response structure (missing series)');
|
||
|
||
const obsMap = series.observations;
|
||
if (!obsMap || typeof obsMap !== 'object') throw new Error('ECB CISS: no observations in response');
|
||
|
||
const timeDim = json?.structure?.dimensions?.observation?.[0]?.values;
|
||
if (!Array.isArray(timeDim) || timeDim.length === 0) throw new Error('ECB CISS: missing time dimension values');
|
||
|
||
// Build sorted history array from index-keyed observations
|
||
const history = Object.entries(obsMap)
|
||
.map(([idxStr, arr]) => {
|
||
const idx = parseInt(idxStr, 10);
|
||
const date = timeDim[idx]?.id ?? null;
|
||
const value = arr?.[0];
|
||
if (!date || typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||
// Validate CISS is in [0, 1] range
|
||
if (value < 0 || value > 1) {
|
||
console.warn(` ECB CISS: value ${value} out of [0,1] range on ${date} — skipping`);
|
||
return null;
|
||
}
|
||
return { date, value };
|
||
})
|
||
.filter(Boolean)
|
||
.sort((a, b) => a.date.localeCompare(b.date));
|
||
|
||
if (history.length === 0) throw new Error('ECB CISS: no valid observations parsed');
|
||
|
||
const latest = history.at(-1);
|
||
const latestValue = latest.value;
|
||
const latestDate = latest.date;
|
||
const label = classifyLabel(latestValue);
|
||
|
||
console.log(` ECB CISS: latest=${latestValue.toFixed(4)} (${latestDate}) label=${label} points=${history.length}`);
|
||
|
||
return {
|
||
seededAt: new Date().toISOString(),
|
||
latestValue,
|
||
latestDate,
|
||
label,
|
||
history,
|
||
unavailable: false,
|
||
};
|
||
}
|
||
|
||
function validate(data) {
|
||
return (
|
||
data?.latestValue != null &&
|
||
Number.isFinite(data.latestValue) &&
|
||
data.latestValue >= 0 &&
|
||
data.latestValue <= 1 &&
|
||
Array.isArray(data.history) &&
|
||
data.history.length > 0
|
||
);
|
||
}
|
||
|
||
// isMain guard — required for scripts that export AND call runSeed at top level.
|
||
// Prevents runSeed() from firing when this module is imported in tests or CI.
|
||
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/^file:\/\//, ''));
|
||
if (isMain) {
|
||
runSeed('economic', 'fsi-eu', FSI_EU_KEY, fetchEcbCiss, {
|
||
validateFn: validate,
|
||
ttlSeconds: FSI_EU_TTL,
|
||
sourceVersion: 'ecb-ciss-sdmx-v1',
|
||
}).catch((err) => {
|
||
console.error('FATAL:', err.message || err);
|
||
process.exit(1);
|
||
});
|
||
}
|