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 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.
61 lines
2.6 KiB
JavaScript
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);
|
|
});
|
|
});
|