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:
Elie Habib
2026-04-13 21:57:11 +04:00
committed by GitHub
parent 7aa8dd1bf2
commit 46d17efe55
5 changed files with 348 additions and 45 deletions

View File

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

View File

@@ -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
View 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 };

View File

@@ -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', () => {

View 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);
});
});