fix(economy): GSCPI shape mismatch with ais-relay payload (#3072)

* fix(economy): GSCPI shape mismatch with ais-relay payload

`seed-economy.mjs` was reporting `[StressIndex] GSCPI not in Redis yet
(ais-relay lag or first run) — excluding` even when GSCPI was current
in Redis. The Stress Index then computed on 5/6 components instead
of 6/6 every run.

Root cause: shape mismatch.
- ais-relay.cjs (`seedGscpi()`) writes the FRED-compatible payload
  `{ series: { series_id, title, units, frequency, observations: [...] } }`
- seed-economy.mjs `fetchGscpiFromRedis()` was reading the legacy
  flat shape `{ observations: [...] }` (top-level), so
  `Array.isArray(parsed.observations)` was always false → null returned
  → "not in Redis yet" log, even though 343 monthly observations were
  sitting in `economic:fred:v1:GSCPI:0`

Fix: extract the parsing into `extractGscpiObservations()` which checks
both shapes (`parsed.series.observations` first, then top-level
`parsed.observations` for back-compat). The "not in Redis yet" message
will now correctly fire only when the relay is genuinely behind.

Verified against live Redis: returns `{ observations: 343 entries,
latest 2026-03-01 = 0.68 }` instead of null.

Tests added in tests/gscpi-shape-extraction.test.mjs (3 cases:
ais-relay shape, legacy flat shape, malformed payload).

* style(economy): single @type cast in extractGscpiObservations

PR #3072 review (P2): cast `parsed` to `any` once into a local instead
of repeating the inline `/** @type {any} */` annotation on every access.
Same behavior, less visual noise.
This commit is contained in:
Elie Habib
2026-04-13 22:03:27 +04:00
committed by GitHub
parent 46d17efe55
commit c072edc89f
2 changed files with 63 additions and 3 deletions

View File

@@ -59,9 +59,23 @@ function stressLabel(score) {
return 'Critical';
}
/**
* Extract GSCPI observations from the Redis-stored payload.
* ais-relay writes the FRED-compatible shape `{ series: { series_id, title, units,
* frequency, observations: [{ date, value }] } }` (see seedGscpi() in ais-relay.cjs).
* Earlier versions stored a flat `{ observations }` shape, so accept both.
* Exported for unit testing.
* @param {unknown} parsed
* @returns {{ observations: { date: string; value: number }[] } | null}
*/
export function extractGscpiObservations(parsed) {
const p = /** @type {any} */ (parsed);
const obs = p?.series?.observations ?? p?.observations;
return Array.isArray(obs) ? { observations: obs } : null;
}
/**
* Read GSCPI from Redis (seeded by ais-relay from NY Fed, not available via FRED API).
* Format stored: { observations: [{ date, value }] } — no series wrapper.
* @returns {Promise<{ observations: { date: string; value: number }[] } | null>}
*/
async function fetchGscpiFromRedis() {
@@ -74,8 +88,7 @@ async function fetchGscpiFromRedis() {
if (!resp.ok) return null;
const body = /** @type {{ result: string | null }} */ (await resp.json());
if (!body.result) return null;
const parsed = JSON.parse(body.result);
return Array.isArray(parsed.observations) ? parsed : null;
return extractGscpiObservations(JSON.parse(body.result));
} catch {
return null;
}

View File

@@ -0,0 +1,47 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { extractGscpiObservations } from '../scripts/seed-economy.mjs';
describe('extractGscpiObservations', () => {
it('reads the ais-relay FRED-compatible shape (observations under .series)', () => {
// This is the actual shape ais-relay.cjs writes — see seedGscpi() in that file.
const parsed = {
series: {
series_id: 'GSCPI',
title: 'Global Supply Chain Pressure Index',
units: 'Standard Deviations',
frequency: 'Monthly',
observations: [
{ date: '2026-02-01', value: 0.42 },
{ date: '2026-03-01', value: 0.68 },
],
},
};
const result = extractGscpiObservations(parsed);
assert.ok(result, 'should return non-null');
assert.equal(result.observations.length, 2);
assert.equal(result.observations[1].value, 0.68);
});
it('reads the legacy flat shape (top-level observations) for back-compat', () => {
// Earlier ais-relay versions stored this shape — keep working if any
// long-lived Redis key still has it.
const parsed = {
observations: [
{ date: '2026-03-01', value: 0.68 },
],
};
const result = extractGscpiObservations(parsed);
assert.ok(result, 'should return non-null');
assert.equal(result.observations.length, 1);
});
it('returns null when neither shape is present', () => {
assert.equal(extractGscpiObservations(null), null);
assert.equal(extractGscpiObservations({}), null);
assert.equal(extractGscpiObservations({ series: {} }), null);
assert.equal(extractGscpiObservations({ observations: 'not-an-array' }), null);
assert.equal(extractGscpiObservations({ series: { observations: 'nope' } }), null);
});
});