Files
worldmonitor/tests/seed-fx-yoy.test.mjs
Elie Habib 46d17efe55 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.
2026-04-13 21:57:11 +04:00

61 lines
2.6 KiB
JavaScript

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