mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(resilience): wider FX YoY upstream + sanctions absolute threshold (#3071)
* fix(resilience): wider FX YoY upstream + sanctions absolute threshold Two backtest families consistently failed Outcome-Backtest gates because the detectors were reading the wrong shape of upstream data, not because the upstream seeders were missing. FX Stress (was AUC=0.500): - BIS WS_EER (`economic:bis:eer:v1`) only covers 12 G10/major-EM countries — Argentina, Egypt, Turkey, Pakistan, Nigeria etc. are absent, so the detector had no positive events to score against - Add `seed-fx-yoy.mjs` fetching Yahoo Finance 2-year monthly history across 45 single-country currencies, computing YoY % and 24-month peak-to-trough drawdown - Switch detector to read drawdown24m with -15% threshold (matches methodology spec); falls back to yoyChange/realChange for back-compat - Why drawdown not just YoY: rolling 12-month windows slice through historic crises (Egypt's March 2024 devaluation falls outside an April→April window by 2026); drawdown captures actual stress magnitude regardless of crisis timing - Verified locally: flags AR (-38%), TR (-28%), NG (-21%), MX (-18%) Sanctions Shocks (was AUC=0.624): - Detector previously used top-quartile (Q3) of country-counts which conflated genuine comprehensive-sanctions targets (RU, IR, KP, CU, SY, VE, BY, MM) with financial hubs (UK, CH, DE, US) hosting many sanctioned entities - Replace with absolute threshold of 100 entities — the OFAC distribution is heavy-tailed enough that this cleanly separates targets from hubs Both fixes use existing seeded data (or new seeded data via seed-fx-yoy.mjs) — no hardcoded country curation. api/health.js: register `economic:fx:yoy:v1` in STANDALONE_KEYS + SEED_META so the validation cron monitors freshness. Railway: deploy `seed-fx-yoy` as a daily cron service (NIXPACKS builder, startCommand `node scripts/seed-fx-yoy.mjs`, schedule `30 6 * * *`). * fix(seed-fx-yoy): use running-peak max-drawdown instead of global peak PR #3071 review (P1): the original drawdown calculation found the global maximum across the entire window, then the lowest point AFTER that peak. This silently erased earlier crashes when the currency later recovered to a new high — exactly the class of events the FX Stress family is trying to capture. Example series [5, 10, 7, 9, 6, 11, 10]: true worst drawdown is 10 → 6 = -40%, but the broken implementation picked the later global peak 11 and reported only 11 → 10 = -9.1%. Fix: sweep forward tracking the running peak; for each subsequent bar compute the drop from that running peak; keep the largest such drop. This is the standard max-drawdown computation and correctly handles recover-then-fall-again sequences. Live data verification: - BR (Brazilian real) was missing from the flagged set under the broken algorithm because BRL recovered above its 2024-04 peak. With the fix it correctly surfaces at drawdown=-15.8% (peak 2024-04, trough 2024-12). - KR / CO peaks now resolve to mid-series dates instead of end-of-window, proving the running-peak scan is finding intermediate peaks. Tests added covering: reviewer's regression case, peak-at-start (NGN style), pure appreciation, multi-trough series, yoyChange anchor. * fix(health): gate fxYoy as on-demand to avoid post-merge CRIT alarm PR #3071 review (P1): registering `fxYoy` as a required standalone seeded key creates an operational hazard during the deploy gap. After merge, Vercel auto-deploys `api/health.js` immediately, but the `seed-fx-yoy` Railway cron lives in a separate deployment surface that must be triggered manually. Any gap (deploy-order race, first-cron failure, env var typo) flips health to DEGRADED/UNHEALTHY because `classifyKey()` marks the check as `EMPTY` without an on-demand or empty-data-OK exemption. Add `fxYoy` to ON_DEMAND_KEYS as a transitional safety net (matches the pattern recovery* uses for "stub seeders not yet deployed"). The key is still monitored — freshness via seed-meta — but missing data downgrades from CRIT to WARN, which won't page anyone. Once the Railway cron has fired cleanly for ~7 days in production we can remove this entry and let it be a hard-required key like the rest of the FRED family. Note: the Railway service IS already provisioned (cron `30 6 * * *`, 0.5 vCPU / 0.5 GB, NIXPACKS, watchPatterns scoped to the seeder + utils) and the `economic:fx:yoy:v1` key is currently fresh in production from local test runs. The gating here is defense-in-depth against the operational coupling, not against a known absent key.
This commit is contained in:
@@ -103,6 +103,7 @@ const STANDALONE_KEYS = {
|
||||
macroSignals: 'economic:macro-signals:v1',
|
||||
bisPolicy: 'economic:bis:policy:v1',
|
||||
bisExchange: 'economic:bis:eer:v1',
|
||||
fxYoy: 'economic:fx:yoy:v1',
|
||||
bisCredit: 'economic:bis:credit:v1',
|
||||
bisDsr: 'economic:bis:dsr:v1',
|
||||
bisPropertyResidential: 'economic:bis:property-residential:v1',
|
||||
@@ -232,6 +233,7 @@ const SEED_META = {
|
||||
chokepoints: { key: 'seed-meta:supply_chain:chokepoints', maxStaleMin: 60 },
|
||||
// minerals + giving: on-demand cachedFetchJson only, no seed-meta writer — freshness checked via TTL
|
||||
// bisExchange + bisCredit: extras written by same BIS script via writeExtraKey, no dedicated seed-meta
|
||||
fxYoy: { key: 'seed-meta:economic:fx-yoy', maxStaleMin: 1500 }, // daily cron; 25h tolerance + 1h drift
|
||||
gpsjam: { key: 'seed-meta:intelligence:gpsjam', maxStaleMin: 720 },
|
||||
positiveGeoEvents:{ key: 'seed-meta:positive-events:geo', maxStaleMin: 60 },
|
||||
riskScores: { key: 'seed-meta:intelligence:risk-scores', maxStaleMin: 30 }, // CII warm-ping every 8min; 30min = ~3.5x interval,
|
||||
@@ -376,6 +378,10 @@ const ON_DEMAND_KEYS = new Set([
|
||||
'recoveryFiscalSpace', 'recoveryReserveAdequacy', 'recoveryExternalDebt',
|
||||
'recoveryImportHhi', 'recoveryFuelStocks', // recovery pillar: stub seeders not yet deployed, keys may be absent
|
||||
'displacementPrev', // covered by cascade onto current-year displacement; empty most of the year
|
||||
'fxYoy', // TRANSITIONAL (PR #3071): seed-fx-yoy Railway cron deployed manually after merge —
|
||||
// gate as on-demand so a deploy-order race or first-cron-run failure doesn't
|
||||
// fire a CRIT health alarm. Remove from this set after ~7 days of clean
|
||||
// production cron runs (verify via `seed-meta:economic:fx-yoy.fetchedAt`).
|
||||
]);
|
||||
|
||||
// Keys where 0 records is a valid healthy state (e.g. no airports closed,
|
||||
|
||||
@@ -42,8 +42,8 @@ const EVENT_FAMILIES = [
|
||||
{
|
||||
id: 'fx-stress',
|
||||
label: 'FX Stress',
|
||||
description: 'Currency depreciation >= 15% in 12 months',
|
||||
redisKey: 'economic:bis:eer:v1',
|
||||
description: 'Currency peak-to-trough drawdown >= 15% over 24 months',
|
||||
redisKey: 'economic:fx:yoy:v1',
|
||||
detect: detectFxStress,
|
||||
dataSource: 'live',
|
||||
},
|
||||
@@ -232,20 +232,32 @@ async function fetchAllResilienceScores(url, token) {
|
||||
return scores;
|
||||
}
|
||||
|
||||
// FX Stress detector — reads the BIS real effective exchange rate payload
|
||||
// (seed-bis-data.mjs, key economic:bis:eer:v1). Shape:
|
||||
// { rates: [{ countryCode: "IN", realEer, nominalEer, realChange, date }] }
|
||||
// `realChange` is YoY percent change (already in percent units, e.g. -1.4
|
||||
// means -1.4%). Stress is a significant depreciation; we flag countries with
|
||||
// realChange <= -15 (drop of 15% or more YoY, matching the family description
|
||||
// "Currency depreciation >= 15% in 12 months").
|
||||
// FX Stress detector — reads the wider-coverage Yahoo FX payload
|
||||
// (seed-fx-yoy.mjs, key economic:fx:yoy:v1). Shape:
|
||||
// { rates: [{ countryCode: "AR", currency: "ARS",
|
||||
// currentRate, yearAgoRate, yoyChange,
|
||||
// drawdown24m, peakRate, peakDate, troughRate, troughDate,
|
||||
// asOf, yearAgo }], fetchedAt }
|
||||
//
|
||||
// DATA-COVERAGE CAVEAT: BIS publishes EER for ~12 advanced + select EM
|
||||
// economies (G10 + a handful of BRICS). Genuine currency crises like Turkey
|
||||
// 2021 or Argentina 2023 won't necessarily appear unless BIS covers them.
|
||||
// The detector is correct; the upstream source is narrow. Expanding to a
|
||||
// broader FX dataset (FRED, IMF IFS, or central-bank direct) would materially
|
||||
// improve this family's signal.
|
||||
// Use peak-to-trough drawdown over the last 24 months as the stress signal,
|
||||
// with a -15% threshold matching the methodology spec. A rolling 12-month
|
||||
// YoY window slices through the middle of historic crises (Egypt's March
|
||||
// 2024 devaluation, Nigeria's June 2023 devaluation, etc. all fall outside
|
||||
// an April→April YoY window by 2026) so YoY-only systematically misses the
|
||||
// very events the family is trying to label. Drawdown captures the actual
|
||||
// magnitude of stress regardless of crisis timing.
|
||||
//
|
||||
// Falls back to yoyChange / realChange for back-compat with the old BIS
|
||||
// payload during transition deploys (eliminates a CRIT-level health flap if
|
||||
// seed-fx-yoy.mjs hasn't run yet but seed-bis-data.mjs has).
|
||||
//
|
||||
// Coverage: ~45 single-country currencies including Argentina (ARS), Egypt
|
||||
// (EGP), Turkey (TRY), Pakistan (PKR), Nigeria (NGN), etc. — the historic
|
||||
// FX-crisis countries that BIS WS_EER (~12 G10 + select EM) excluded.
|
||||
// Multi-country shared currencies (EUR, XOF, XAF) are intentionally absent
|
||||
// because depreciation can't be attributed to any single member.
|
||||
const FX_STRESS_THRESHOLD_PCT = -15;
|
||||
|
||||
function detectFxStress(data) {
|
||||
const labels = new Map();
|
||||
if (!data || typeof data !== 'object') return labels;
|
||||
@@ -254,9 +266,13 @@ function detectFxStress(data) {
|
||||
for (const entry of rates) {
|
||||
const iso2 = toIso2(entry);
|
||||
if (!iso2) continue;
|
||||
const change = entry.realChange ?? entry.yoyChange ?? entry.change ?? entry.depreciation;
|
||||
const change = entry.drawdown24m
|
||||
?? entry.yoyChange
|
||||
?? entry.realChange
|
||||
?? entry.change
|
||||
?? entry.depreciation;
|
||||
if (typeof change === 'number' && Number.isFinite(change)) {
|
||||
labels.set(iso2, change <= -15);
|
||||
labels.set(iso2, change <= FX_STRESS_THRESHOLD_PCT);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
@@ -367,12 +383,20 @@ function detectRefugeeSurges(data) {
|
||||
// (key sanctions:country-counts:v1). Shape:
|
||||
// { "CU": 35, "GB": 190, "CH": 98, ... }
|
||||
// This is the cumulative count of sanctioned entities linked to each ISO2,
|
||||
// not a yearly delta. Every country in the map has count > 0 by definition,
|
||||
// so the previous `count > 0` gate labeled all 70+ countries as positive —
|
||||
// AUC collapsed to ~0.53 noise. Use the top-quartile (>= Q3) as the
|
||||
// threshold so only countries with an outlier number of sanctioned entities
|
||||
// (likely genuine sanctions targets, not financial hubs that merely host
|
||||
// sanctioned entities) get flagged.
|
||||
// not a yearly delta. The OFAC distribution is heavily right-skewed:
|
||||
// comprehensive-sanctions targets (RU, IR, KP, CU, SY, VE, BY, MM) carry
|
||||
// hundreds-to-thousands of entities each. The long tail (financial hubs +
|
||||
// incidental nexus countries) carries dozens.
|
||||
//
|
||||
// The previous Q3 (top-quartile) gate flagged ~50 countries — AUC stuck
|
||||
// at ~0.62 because half the flags were financial hubs that merely host
|
||||
// sanctioned entities, not the targets themselves. An absolute threshold
|
||||
// of SANCTIONS_TARGET_THRESHOLD entities is a more semantically meaningful
|
||||
// cutoff: a country with 100+ designated entities IS being heavily
|
||||
// targeted, regardless of how the rest of the distribution looks. Review
|
||||
// annually as global sanctions volume evolves.
|
||||
const SANCTIONS_TARGET_THRESHOLD = 100;
|
||||
|
||||
function detectSanctionsShocks(data) {
|
||||
const labels = new Map();
|
||||
if (!data || typeof data !== 'object') return labels;
|
||||
@@ -394,15 +418,8 @@ function detectSanctionsShocks(data) {
|
||||
}
|
||||
}
|
||||
|
||||
if (counts.size === 0) return labels;
|
||||
|
||||
// Top-quartile threshold from the observed distribution. Minimum floor of
|
||||
// 10 so a degenerate tiny payload doesn't flag everyone.
|
||||
const sorted = [...counts.values()].sort((a, b) => a - b);
|
||||
const q3 = sorted[Math.floor(sorted.length * 0.75)] ?? 0;
|
||||
const threshold = Math.max(10, q3);
|
||||
for (const [iso2, c] of counts) {
|
||||
if (c >= threshold) labels.set(iso2, true);
|
||||
if (c >= SANCTIONS_TARGET_THRESHOLD) labels.set(iso2, true);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
178
scripts/seed-fx-yoy.mjs
Normal file
178
scripts/seed-fx-yoy.mjs
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Wider-coverage FX year-over-year + peak-to-trough drawdown seed.
|
||||
*
|
||||
* Yahoo Finance historical chart API (range=2y, interval=1mo) per currency.
|
||||
* For each currency we compute:
|
||||
* - yoyChange: % change between the bar 12 months ago and the latest
|
||||
* - drawdown24m: worst peak-to-trough % loss over the last 24 monthly bars
|
||||
*
|
||||
* Why both: a rolling 12-month window slices through the middle of historic
|
||||
* crises (Egypt's March 2024 devaluation, Nigeria's June 2023 devaluation
|
||||
* etc. all fall outside an April→April YoY window by 2026). The 24-month
|
||||
* peak-to-trough signal captures the actual crisis magnitude even when the
|
||||
* crisis anniversary has passed.
|
||||
*
|
||||
* Why this exists: BIS WS_EER (`economic:bis:eer:v1`) only covers 12 G10 +
|
||||
* select EM economies — none of which experience the FX moves the resilience
|
||||
* methodology's FX Stress family actually targets (Argentina, Egypt, Turkey,
|
||||
* Pakistan, Nigeria, etc. are absent from BIS coverage). Yahoo Finance
|
||||
* covers the full set of currencies needed for this signal.
|
||||
*
|
||||
* Output key `economic:fx:yoy:v1` shape:
|
||||
* {
|
||||
* rates: [
|
||||
* { countryCode: "AR", currency: "ARS",
|
||||
* currentRate, yearAgoRate, yoyChange,
|
||||
* drawdown24m, peakRate, peakDate, troughRate, troughDate,
|
||||
* asOf, yearAgo },
|
||||
* ...
|
||||
* ],
|
||||
* fetchedAt: "<iso>",
|
||||
* }
|
||||
*
|
||||
* Railway: deploy as cron service running daily (e.g. `30 6 * * *`),
|
||||
* NIXPACKS builder, startCommand `node scripts/seed-fx-yoy.mjs`.
|
||||
*/
|
||||
|
||||
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
|
||||
|
||||
loadEnvFile(import.meta.url);
|
||||
|
||||
const CANONICAL_KEY = 'economic:fx:yoy:v1';
|
||||
const CACHE_TTL = 25 * 3600; // 25h covers a daily cron + 1h drift buffer
|
||||
|
||||
// Currency → primary ISO2 country. Multi-country currencies (EUR, XOF, XAF,
|
||||
// XCD, XPF) are intentionally omitted because shared-currency depreciation
|
||||
// shouldn't flag any individual member as country-specific FX stress.
|
||||
const CURRENCY_COUNTRY = {
|
||||
// Americas
|
||||
CAD: 'CA', MXN: 'MX', BRL: 'BR', ARS: 'AR', COP: 'CO', CLP: 'CL',
|
||||
// Europe (non-EUR)
|
||||
GBP: 'GB', CHF: 'CH', NOK: 'NO', SEK: 'SE', DKK: 'DK',
|
||||
PLN: 'PL', CZK: 'CZ', HUF: 'HU', RON: 'RO', UAH: 'UA',
|
||||
// Asia-Pacific
|
||||
CNY: 'CN', JPY: 'JP', KRW: 'KR', AUD: 'AU', NZD: 'NZ',
|
||||
SGD: 'SG', HKD: 'HK', TWD: 'TW', THB: 'TH', MYR: 'MY',
|
||||
IDR: 'ID', PHP: 'PH', VND: 'VN', INR: 'IN', PKR: 'PK',
|
||||
// Middle East
|
||||
AED: 'AE', SAR: 'SA', QAR: 'QA', KWD: 'KW', BHD: 'BH',
|
||||
OMR: 'OM', JOD: 'JO', EGP: 'EG', LBP: 'LB', ILS: 'IL',
|
||||
TRY: 'TR',
|
||||
// Africa
|
||||
ZAR: 'ZA', NGN: 'NG', KES: 'KE',
|
||||
};
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const PER_CURRENCY_DELAY_MS = 120;
|
||||
|
||||
async function fetchYahooHistory(currency) {
|
||||
const symbol = `${currency}USD=X`;
|
||||
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?range=2y&interval=1mo`;
|
||||
const resp = await fetch(url, {
|
||||
headers: { 'User-Agent': CHROME_UA },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Yahoo HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const result = data?.chart?.result?.[0];
|
||||
const timestamps = result?.timestamp;
|
||||
const closes = result?.indicators?.quote?.[0]?.close;
|
||||
if (!Array.isArray(timestamps) || !Array.isArray(closes)) {
|
||||
throw new Error('Yahoo chart payload missing timestamp/close arrays');
|
||||
}
|
||||
const series = [];
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
const close = closes[i];
|
||||
if (typeof close === 'number' && Number.isFinite(close) && close > 0) {
|
||||
series.push({ t: timestamps[i] * 1000, close });
|
||||
}
|
||||
}
|
||||
if (series.length < 13) throw new Error(`Insufficient bars (${series.length})`);
|
||||
return series;
|
||||
}
|
||||
|
||||
function computeYoy(series) {
|
||||
const latest = series[series.length - 1];
|
||||
// For range=2y, look back 12 bars from the end to get the YoY anchor.
|
||||
const yearAgoIdx = Math.max(0, series.length - 13);
|
||||
const yearAgo = series[yearAgoIdx];
|
||||
const yoyChange = ((latest.close - yearAgo.close) / yearAgo.close) * 100;
|
||||
|
||||
// Worst peak-to-trough drawdown over the available window using a
|
||||
// running-peak scan. For USD pairs in the form {CCY}USD=X, the close
|
||||
// is the price of 1 unit of CCY in USD — so a drop = currency
|
||||
// depreciation against USD.
|
||||
//
|
||||
// A naive "global max → min after global max" approach erases earlier
|
||||
// crashes that were followed by a partial recovery to a new high
|
||||
// (e.g. series [10, 6, 11, 10] — true worst drawdown is 10→6=-40%, but
|
||||
// global-peak-after picks 11→10=-9.1%). Track the running peak as we
|
||||
// sweep forward and record the largest drop from that peak to any
|
||||
// subsequent point — exactly what max-drawdown means in a time series.
|
||||
let runningPeak = series[0];
|
||||
let worstDrawdown = 0;
|
||||
let peakAtWorst = series[0];
|
||||
let troughAtWorst = series[0];
|
||||
for (const bar of series) {
|
||||
if (bar.close > runningPeak.close) runningPeak = bar;
|
||||
const dd = ((bar.close - runningPeak.close) / runningPeak.close) * 100;
|
||||
if (dd < worstDrawdown) {
|
||||
worstDrawdown = dd;
|
||||
peakAtWorst = runningPeak;
|
||||
troughAtWorst = bar;
|
||||
}
|
||||
}
|
||||
const drawdown24m = worstDrawdown;
|
||||
const peak = peakAtWorst;
|
||||
const trough = troughAtWorst;
|
||||
|
||||
return {
|
||||
currentRate: latest.close,
|
||||
yearAgoRate: yearAgo.close,
|
||||
yoyChange: Math.round(yoyChange * 10) / 10,
|
||||
drawdown24m: Math.round(drawdown24m * 10) / 10,
|
||||
peakRate: peak.close,
|
||||
peakDate: new Date(peak.t).toISOString().slice(0, 10),
|
||||
troughRate: trough.close,
|
||||
troughDate: new Date(trough.t).toISOString().slice(0, 10),
|
||||
asOf: new Date(latest.t).toISOString().slice(0, 10),
|
||||
yearAgo: new Date(yearAgo.t).toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchFxYoy() {
|
||||
const rates = [];
|
||||
const failures = [];
|
||||
for (const [currency, countryCode] of Object.entries(CURRENCY_COUNTRY)) {
|
||||
try {
|
||||
const series = await fetchYahooHistory(currency);
|
||||
const yoy = computeYoy(series);
|
||||
rates.push({ countryCode, currency, ...yoy });
|
||||
} catch (err) {
|
||||
failures.push({ currency, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, PER_CURRENCY_DELAY_MS));
|
||||
}
|
||||
console.log(` FX YoY: ${rates.length}/${Object.keys(CURRENCY_COUNTRY).length} currencies`);
|
||||
if (failures.length > 0) {
|
||||
console.log(` Failures: ${failures.map((f) => `${f.currency}(${f.error})`).join(', ')}`);
|
||||
}
|
||||
if (rates.length === 0) {
|
||||
throw new Error('All Yahoo FX history fetches failed');
|
||||
}
|
||||
return { rates, fetchedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMain) {
|
||||
await runSeed('economic', 'fx-yoy', CANONICAL_KEY, fetchFxYoy, {
|
||||
ttlSeconds: CACHE_TTL,
|
||||
validateFn: (data) => Array.isArray(data?.rates) && data.rates.length >= 10,
|
||||
recordCount: (data) => data?.rates?.length ?? 0,
|
||||
sourceVersion: 'yahoo-fx-yoy-v1',
|
||||
});
|
||||
}
|
||||
|
||||
export { CURRENCY_COUNTRY, computeYoy, fetchFxYoy };
|
||||
@@ -96,8 +96,39 @@ describe('checkGate', () => {
|
||||
|
||||
describe('event detectors', () => {
|
||||
describe('detectFxStress', () => {
|
||||
// Real seed-bis-data.mjs payload shape: { rates: [{ countryCode, realEer, nominalEer, realChange, date }] }
|
||||
it('detects country with <=-15% realChange from the BIS rates payload', () => {
|
||||
// Real seed-fx-yoy.mjs payload shape:
|
||||
// { rates: [{ countryCode, currency, currentRate, yearAgoRate, yoyChange,
|
||||
// drawdown24m, peakRate, peakDate, troughRate, troughDate,
|
||||
// asOf, yearAgo }] }
|
||||
it('detects country with <=-15% drawdown24m from the FX payload', () => {
|
||||
const data = {
|
||||
rates: [
|
||||
{ countryCode: 'AR', currency: 'ARS', drawdown24m: -38.4, yoyChange: -13.2 },
|
||||
{ countryCode: 'EG', currency: 'EGP', drawdown24m: -22.4, yoyChange: -6.7 },
|
||||
{ countryCode: 'NG', currency: 'NGN', drawdown24m: -20.9, yoyChange: 17.3 },
|
||||
{ countryCode: 'JP', currency: 'JPY', drawdown24m: -10.0, yoyChange: -9.6 },
|
||||
],
|
||||
};
|
||||
const labels = detectFxStress(data);
|
||||
assert.equal(labels.get('AR'), true, 'Argentina drawdown 38% — flagged');
|
||||
assert.equal(labels.get('EG'), true, 'Egypt drawdown 22% — flagged (YoY would have missed this)');
|
||||
assert.equal(labels.get('NG'), true, 'Nigeria drawdown 21% — flagged (YoY shows recovery, drawdown captures crisis)');
|
||||
assert.equal(labels.get('JP'), false, 'Japan drawdown 10% — below threshold');
|
||||
});
|
||||
|
||||
it('falls back to yoyChange when drawdown24m is absent', () => {
|
||||
const data = {
|
||||
rates: [
|
||||
{ countryCode: 'AR', currency: 'ARS', yoyChange: -22.4 },
|
||||
{ countryCode: 'JP', currency: 'JPY', yoyChange: -3.0 },
|
||||
],
|
||||
};
|
||||
const labels = detectFxStress(data);
|
||||
assert.equal(labels.get('AR'), true);
|
||||
assert.equal(labels.get('JP'), false);
|
||||
});
|
||||
|
||||
it('falls back to legacy BIS realChange field for back-compat', () => {
|
||||
const data = {
|
||||
rates: [
|
||||
{ countryCode: 'TR', realEer: 55.1, realChange: -22.4, date: '2026-02' },
|
||||
@@ -116,7 +147,7 @@ describe('event detectors', () => {
|
||||
});
|
||||
|
||||
it('resolves full country names via resolveIso2 when countryCode is absent', () => {
|
||||
const data = { rates: [{ country: 'Turkey', realChange: -20 }] };
|
||||
const data = { rates: [{ country: 'Turkey', drawdown24m: -27.9 }] };
|
||||
const labels = detectFxStress(data);
|
||||
assert.equal(labels.get('TR'), true);
|
||||
});
|
||||
@@ -213,20 +244,31 @@ describe('event detectors', () => {
|
||||
|
||||
describe('detectSanctionsShocks', () => {
|
||||
// Real seed-sanctions-pressure.mjs shape: { ISO2: entryCount, ... }
|
||||
// Top-quartile threshold (min floor 10) picks out genuinely sanctions-
|
||||
// heavy countries vs financial hubs that merely host sanctioned entities.
|
||||
it('flags top-quartile countries by cumulative sanctioned-entity count', () => {
|
||||
// Absolute threshold of 100 entities isolates comprehensive-sanctions
|
||||
// targets from financial hubs that merely host sanctioned entities.
|
||||
it('flags only countries above the 100-entity threshold', () => {
|
||||
const data = {
|
||||
RU: 500, IR: 400, KP: 300, // top quartile
|
||||
CN: 50, CU: 40, VE: 30, SY: 20, // mid range — below q3
|
||||
FR: 5, DE: 3, JP: 1, // noise
|
||||
RU: 8000, IR: 1200, KP: 800, CU: 600, SY: 500, VE: 450, BY: 400, MM: 350,
|
||||
// Financial hubs with sub-threshold counts (Q3 gate would have flagged these):
|
||||
GB: 90, CH: 80, DE: 70, US: 60, AE: 50,
|
||||
// Long tail of incidental nexus entities:
|
||||
FR: 30, JP: 15, CA: 10, AU: 8, IT: 5, NL: 3,
|
||||
};
|
||||
const labels = detectSanctionsShocks(data);
|
||||
assert.equal(labels.get('RU'), true);
|
||||
assert.equal(labels.get('IR'), true);
|
||||
assert.equal(labels.has('FR'), false, 'France/DE/JP have low counts — not a sanctions target');
|
||||
// The previous `count > 0` gate flagged all 10 — regression would re-expand the label set.
|
||||
assert.ok(labels.size < 10, `expected top-quartile filter, got ${labels.size} labels`);
|
||||
assert.equal(labels.get('RU'), true, 'Russia: 8000 entries, comprehensive sanctions');
|
||||
assert.equal(labels.get('IR'), true, 'Iran: 1200 entries, comprehensive sanctions');
|
||||
assert.equal(labels.get('KP'), true, 'North Korea: 800 entries');
|
||||
assert.equal(labels.get('MM'), true, 'Myanmar: 350 entries — comprehensive sanctions');
|
||||
assert.equal(labels.has('GB'), false, 'UK: 90 entries below threshold — financial hub, not target');
|
||||
assert.equal(labels.has('CH'), false, 'Switzerland: 80 entries below threshold');
|
||||
assert.equal(labels.has('FR'), false, 'France: noise level');
|
||||
assert.equal(labels.size, 8, 'exactly the 8 comprehensive-sanctions targets');
|
||||
});
|
||||
|
||||
it('flags nothing in a tiny-payload edge case (no country above threshold)', () => {
|
||||
const data = { US: 90, GB: 50, FR: 20, DE: 10 };
|
||||
const labels = detectSanctionsShocks(data);
|
||||
assert.equal(labels.size, 0, 'no country above threshold — none flagged');
|
||||
});
|
||||
|
||||
it('returns empty for null data', () => {
|
||||
|
||||
60
tests/seed-fx-yoy.test.mjs
Normal file
60
tests/seed-fx-yoy.test.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { computeYoy } from '../scripts/seed-fx-yoy.mjs';
|
||||
|
||||
// Build a synthetic monthly series with sequential timestamps. The bar values
|
||||
// represent the USD price of 1 unit of the foreign currency (e.g. ARSUSD=X)
|
||||
// — so a price drop = currency depreciation against USD.
|
||||
function makeSeries(closes) {
|
||||
const month = 30 * 86400 * 1000;
|
||||
return closes.map((close, i) => ({ t: 1700000000_000 + i * month, close }));
|
||||
}
|
||||
|
||||
describe('computeYoy — peak-to-trough drawdown', () => {
|
||||
it('finds the worst drawdown even when the currency later recovers to a new high', () => {
|
||||
// PR #3071 review regression case: a naive "global max → min after"
|
||||
// implementation would pick the later peak of 11 and report only 11→10
|
||||
// = -9.1%, missing the real 10→6 = -40% crash earlier in the series.
|
||||
const series = makeSeries([5, 10, 7, 9, 6, 11, 10]);
|
||||
const r = computeYoy(series);
|
||||
assert.equal(r.drawdown24m, -40, 'true worst drawdown is 10→6');
|
||||
assert.equal(r.peakRate, 10);
|
||||
assert.equal(r.troughRate, 6);
|
||||
});
|
||||
|
||||
it('handles the trivial case where the peak is the first bar (no recovery)', () => {
|
||||
// NGN-style: currency at multi-year high at start, depreciates monotonically.
|
||||
const series = makeSeries([10, 9, 8, 7, 6, 7, 8, 7]);
|
||||
const r = computeYoy(series);
|
||||
assert.equal(r.drawdown24m, -40);
|
||||
assert.equal(r.peakRate, 10);
|
||||
assert.equal(r.troughRate, 6);
|
||||
});
|
||||
|
||||
it('returns 0 drawdown for a series that only appreciates', () => {
|
||||
const series = makeSeries([5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16, 17, 18]);
|
||||
const r = computeYoy(series);
|
||||
assert.equal(r.drawdown24m, 0);
|
||||
});
|
||||
|
||||
it('records the right peak/trough dates for a multi-trough series', () => {
|
||||
// Earlier trough (8→4 = -50%) is worse than later one (8→6 = -25%).
|
||||
const series = makeSeries([8, 4, 7, 8, 6, 8]);
|
||||
const r = computeYoy(series);
|
||||
assert.equal(r.drawdown24m, -50);
|
||||
assert.equal(r.peakRate, 8);
|
||||
assert.equal(r.troughRate, 4);
|
||||
});
|
||||
|
||||
it('computes yoyChange from the bar 12 months before the latest', () => {
|
||||
// 25 monthly bars: yoyChange should compare bar[24] to bar[12].
|
||||
// Use closes that distinguish from drawdown so we don't conflate.
|
||||
const closes = Array.from({ length: 25 }, (_, i) => 100 - i * 2); // monotonic decline
|
||||
const series = makeSeries(closes);
|
||||
const r = computeYoy(series);
|
||||
// Latest = 100 - 24*2 = 52, yearAgo = 100 - 12*2 = 76
|
||||
// yoyChange = (52 - 76) / 76 * 100 = -31.578...
|
||||
assert.equal(r.yoyChange, -31.6);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user