Skip to content

Commit cd68e79

Browse files
davduncclaude
andcommitted
fix(USMetrics): anchor trend windows to the latest observation; report rate series in pp
calculateTrend() filtered observations to those on/after (today - N years) and took the first survivor as the window start. Monthly and quarterly FRED series lag the calendar by 1-3 months, so the "1Y" column silently covered 9 months for CPI and 6 months for real GDP (CPI +2.8% vs the true +3.4% YoY; GDPC1 +0.9% vs +2.1%). Missing observations ("." in FRED) pushed the start further forward. - Anchor every window to the latest observation and pick the start as the observation nearest to (latest - N years); return null when nothing is within 183 days of the target (sparse annual series). - Sort observations by date before windowing. - Series that are already percentages (UNRATE, FEDFUNDS, DGS10, ...) now report changes in percentage points instead of percent-of-percent ("Fed Funds +807% over 10 years", "GDP Growth Rate +200%"); direction uses a 0.10pp dead band. Extremes and momentum sections use the same units. - Export the pure functions and guard main() with import.meta.main so the module is testable; add GenerateAnalysis.test.ts with fixed-date fixtures. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 5e2f2e8 commit cd68e79

2 files changed

Lines changed: 255 additions & 57 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { calculateTrend, formatChange, formatDelta, type Observation } from "./GenerateAnalysis";
3+
4+
// All fixtures use fixed dates so the tests never depend on the wall clock.
5+
6+
/** Observations on the 1st of every `stepMonths` months from `from` to `to` (inclusive), skipping `skip` dates. */
7+
function periodic(from: string, to: string, valueAt: (i: number) => number, skip: string[] = [], stepMonths = 1): Observation[] {
8+
const out: Observation[] = [];
9+
let [y, m] = from.split("-").map(Number);
10+
const [ty, tm] = to.split("-").map(Number);
11+
for (let i = 0; y < ty || (y === ty && m <= tm); i++) {
12+
const date = `${y}-${String(m).padStart(2, "0")}-01`;
13+
if (!skip.includes(date)) out.push({ date, value: valueAt(i) });
14+
m += stepMonths;
15+
while (m > 12) { m -= 12; y++; }
16+
}
17+
return out;
18+
}
19+
20+
/** Business-day observations (Mon-Fri) from `from` to `to`, inclusive. */
21+
function businessDaily(from: string, to: string, valueAt: (i: number) => number): Observation[] {
22+
const out: Observation[] = [];
23+
for (let d = new Date(from), i = 0; d <= new Date(to); d.setUTCDate(d.getUTCDate() + 1), i++) {
24+
const dow = d.getUTCDay();
25+
if (dow !== 0 && dow !== 6) out.push({ date: d.toISOString().slice(0, 10), value: valueAt(i) });
26+
}
27+
return out;
28+
}
29+
30+
describe("calculateTrend anchors the window to the latest observation", () => {
31+
// Values rise 1.0 per observation, so the change equals the number of periods spanned.
32+
const monthly = periodic("2015-08", "2026-08", i => 100 + i);
33+
34+
test("monthly series ending Aug 2026: 1Y start is Aug 2025 regardless of today", () => {
35+
const t = calculateTrend(monthly, 1)!;
36+
expect(t.endDate).toBe("2026-08-01");
37+
expect(t.startDate).toBe("2025-08-01");
38+
expect(t.change).toBe(12);
39+
});
40+
41+
test("10Y window lands exactly ten years before the latest observation", () => {
42+
const t = calculateTrend(monthly, 10)!;
43+
expect(t.startDate).toBe("2016-08-01");
44+
expect(t.change).toBe(120);
45+
});
46+
47+
test("quarterly series ending Q2 2026: 1Y start is Q2 2025", () => {
48+
const quarterly = periodic("2016-01", "2026-04", i => i, [], 3);
49+
const t = calculateTrend(quarterly, 1)!;
50+
expect(t.endDate).toBe("2026-04-01");
51+
expect(t.startDate).toBe("2025-04-01");
52+
expect(t.change).toBe(4);
53+
});
54+
55+
test("missing observations next to the target do not shift the start", () => {
56+
// FRED returns "." for some periods; the caller drops them before windowing.
57+
const t = calculateTrend(periodic("2015-08", "2026-08", i => 100 + i, ["2025-07-01", "2025-09-01", "2025-10-01"]), 1)!;
58+
expect(t.startDate).toBe("2025-08-01");
59+
expect(t.change).toBe(12);
60+
});
61+
62+
test("a missing target month returns null rather than a mislabelled 13-month window", () => {
63+
expect(calculateTrend(periodic("2015-08", "2026-08", i => 100 + i, ["2025-08-01"]), 1)).toBeNull();
64+
});
65+
66+
test("quarterly series with a hole at the target returns null", () => {
67+
const quarterly = periodic("2016-01", "2026-04", i => i, ["2025-04-01"], 3);
68+
expect(calculateTrend(quarterly, 1)).toBeNull();
69+
});
70+
71+
test("annual series: 1Y start is the prior year's observation", () => {
72+
const annual = periodic("2014-01", "2024-01", i => 40 + i, [], 12);
73+
const t = calculateTrend(annual, 1)!;
74+
expect(t.startDate).toBe("2023-01-01");
75+
expect(t.change).toBe(1);
76+
});
77+
78+
test("sparse annual series with no point near the target returns null", () => {
79+
const obs: Observation[] = [
80+
{ date: "2020-01-01", value: 40 },
81+
{ date: "2024-01-01", value: 42 },
82+
];
83+
expect(calculateTrend(obs, 1)).toBeNull(); // target 2023-01-01, nearest is 3 years away
84+
});
85+
86+
test("daily series whose target falls on a weekend uses the nearest business day", () => {
87+
const daily = businessDaily("2025-06-02", "2026-08-31", i => i); // 2026-08-31 is a Monday
88+
const t = calculateTrend(daily, 1)!;
89+
expect(t.endDate).toBe("2026-08-31");
90+
expect(t.startDate).toBe("2025-09-01"); // 2025-08-31 is a Sunday; Monday (1 day) beats Friday (2 days)
91+
});
92+
93+
test("fewer than two observations returns null", () => {
94+
expect(calculateTrend([{ date: "2026-08-01", value: 1 }], 1)).toBeNull();
95+
expect(calculateTrend([], 1)).toBeNull();
96+
});
97+
});
98+
99+
describe("rate series are reported in percentage points", () => {
100+
test("change is in pp and direction uses a 0.1pp dead band", () => {
101+
const rate = periodic("2024-08", "2026-08", i => 4.5 - i * 0.05); // falls 0.05pp per month
102+
const t = calculateTrend(rate, 1, true)!;
103+
expect(t.unit).toBe("pp");
104+
expect(t.delta).toBeCloseTo(-0.6, 6);
105+
expect(t.direction).toBe("↓");
106+
expect(formatChange(t)).toBe("-0.60pp");
107+
});
108+
109+
test("a flat rate series is '→' even though its percent change is not tiny", () => {
110+
const flat = periodic("2024-08", "2026-08", i => 0.5 + (i % 2) * 0.05); // wobbles 0.05pp around 0.5%
111+
expect(calculateTrend(flat, 1, true)!.direction).toBe("→");
112+
});
113+
114+
test("level series report percent change", () => {
115+
const t = calculateTrend(periodic("2024-08", "2026-08", i => 300 + i), 1)!;
116+
expect(t.unit).toBe("%");
117+
expect(t.startDate).toBe("2025-08-01");
118+
expect(formatChange(t)).toBe("+3.8%"); // 312 -> 324 = +3.846%
119+
});
120+
121+
test("formatChange and formatDelta handle missing or non-finite values", () => {
122+
expect(formatChange(null)).toBe("—");
123+
expect(formatDelta(Infinity, "%")).toBe("—");
124+
expect(formatDelta(0.734, "pp")).toBe("+0.73pp");
125+
});
126+
});

0 commit comments

Comments
 (0)