From 46d17efe551693e728aa9e19fc1889bd34147363 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Mon, 13 Apr 2026 21:57:11 +0400 Subject: [PATCH] fix(resilience): wider FX YoY upstream + sanctions absolute threshold (#3071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- api/health.js | 6 + scripts/backtest-resilience-outcomes.mjs | 79 +++++---- scripts/seed-fx-yoy.mjs | 178 ++++++++++++++++++++ tests/backtest-resilience-outcomes.test.mjs | 70 ++++++-- tests/seed-fx-yoy.test.mjs | 60 +++++++ 5 files changed, 348 insertions(+), 45 deletions(-) create mode 100644 scripts/seed-fx-yoy.mjs create mode 100644 tests/seed-fx-yoy.test.mjs diff --git a/api/health.js b/api/health.js index 275a52450..c42ace295 100644 --- a/api/health.js +++ b/api/health.js @@ -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, diff --git a/scripts/backtest-resilience-outcomes.mjs b/scripts/backtest-resilience-outcomes.mjs index 99d56bb23..ede16c14c 100644 --- a/scripts/backtest-resilience-outcomes.mjs +++ b/scripts/backtest-resilience-outcomes.mjs @@ -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; } diff --git a/scripts/seed-fx-yoy.mjs b/scripts/seed-fx-yoy.mjs new file mode 100644 index 000000000..e5639c170 --- /dev/null +++ b/scripts/seed-fx-yoy.mjs @@ -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: "", + * } + * + * 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 }; diff --git a/tests/backtest-resilience-outcomes.test.mjs b/tests/backtest-resilience-outcomes.test.mjs index e8a62234e..32cfd61c3 100644 --- a/tests/backtest-resilience-outcomes.test.mjs +++ b/tests/backtest-resilience-outcomes.test.mjs @@ -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', () => { diff --git a/tests/seed-fx-yoy.test.mjs b/tests/seed-fx-yoy.test.mjs new file mode 100644 index 000000000..394f002dc --- /dev/null +++ b/tests/seed-fx-yoy.test.mjs @@ -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); + }); +});