File size: 6,152 Bytes
ee888e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
/**
 * #4922 (b): macro-print actuals β€” pure helpers.
 *
 * The economic calendar previously shipped every event with
 * actual:'', estimate:'', previous:'' forever β€” the print itself (the
 * single most market-moving datum) was never captured. FRED publishes
 * the values same-day in series observations; these helpers turn the
 * latest observations into event-ready actual/previous strings.
 *
 * Series/transform map (release calendars use RELEASE ids; values need
 * SERIES ids β€” they are different namespaces):
 *   - CPI / PCE / Retail Sales: index levels β†’ month-over-month % change
 *   - Nonfarm Payrolls: level in thousands β†’ monthly change in K
 *   - GDP: A191RL1Q225SBEA is already the headline annualized % change
 */

export const EVENT_SERIES = {
  CPI: { series: 'CPIAUCSL', transform: 'pct_mom', freq: 'm' },
  'Nonfarm Payrolls': { series: 'PAYEMS', transform: 'diff_k', freq: 'm' },
  GDP: { series: 'A191RL1Q225SBEA', transform: 'direct', freq: 'q' },
  PCE: { series: 'PCEPI', transform: 'pct_mom', freq: 'm' },
  'Retail Sales': { series: 'RSAFS', transform: 'pct_mom', freq: 'm' },
};

/**
 * #4929 external review (P1): on release day BEFORE the print lands,
 * FRED's latest observation is still the PRIOR period β€” filling the
 * event with it would present a stale number as today's print. The
 * observation must belong to the period the release reports: monthly
 * releases report the previous calendar month (diff == 1); quarterly
 * GDP estimates report the previous quarter (1..5 months back across
 * advance/second/third estimates).
 */
export function observationMatchesRelease(eventDateISO, obsDateISO, freq) {
  const eventDate = new Date(eventDateISO);
  const obsDate = new Date(obsDateISO);
  if (!Number.isFinite(eventDate.getTime()) || !Number.isFinite(obsDate.getTime())) return false;
  const monthDiff = (eventDate.getUTCFullYear() - obsDate.getUTCFullYear()) * 12
    + (eventDate.getUTCMonth() - obsDate.getUTCMonth());
  if (freq === 'q') return monthDiff >= 1 && monthDiff <= 5;
  return monthDiff === 1;
}

/** FRED marks missing observations with the string '.'. */
function parseObs(observation) {
  const value = Number.parseFloat(observation?.value);
  return Number.isFinite(value) ? { date: observation.date, value } : null;
}

/** Consecutive-period guard: pct_mom/diff_k compare ADJACENT periods; a
 * missing month in between would silently mislabel a 2-month change as
 * month-over-month (review finding). 45 days tolerates publication
 * jitter on monthly series. */
const MAX_ADJACENT_GAP_MS = 45 * 86400_000;
function isAdjacent(newer, older) {
  const gap = new Date(newer.date).getTime() - new Date(older.date).getTime();
  return Number.isFinite(gap) && gap > 0 && gap <= MAX_ADJACENT_GAP_MS;
}

/**
 * @param {Array<{ date: string; value: string }>} observations DESC-sorted
 *   FRED observations (newest first).
 * @param {'pct_mom'|'diff_k'|'direct'} transform
 * @returns {{ actual: string; previous: string; obsDate: string }}
 *   Empty strings when there is not enough usable data.
 */
export function computePrintValues(observations, transform) {
  const empty = { actual: '', previous: '', obsDate: '' };
  const raw = Array.isArray(observations) ? observations : [];
  // The LEADING observation must be parseable β€” if the newest value is a
  // '.' placeholder, the print is not out yet; do NOT fall back to an
  // older row and present it as current (review finding).
  const lead = parseObs(raw[0]);
  if (!lead) return empty;
  const usable = raw.map(parseObs).filter(Boolean);

  // Values are UNITLESS β€” event.unit already carries '%'/'K' and the
  // calendar renderer appends it; embedding the unit here double-renders.
  if (transform === 'direct') {
    const prev = usable.length > 1 ? usable[1] : null;
    return {
      actual: lead.value.toFixed(1),
      previous: prev ? prev.value.toFixed(1) : '',
      obsDate: lead.date,
    };
  }
  if (usable.length < 2 || !isAdjacent(usable[0], usable[1])) return empty;
  if (transform === 'diff_k') {
    const actual = usable[0].value - usable[1].value;
    const hasPrev = usable.length > 2 && isAdjacent(usable[1], usable[2]);
    const previous = hasPrev ? usable[1].value - usable[2].value : null;
    return {
      actual: `${actual >= 0 ? '+' : ''}${Math.round(actual)}`,
      previous: previous === null ? '' : `${previous >= 0 ? '+' : ''}${Math.round(previous)}`,
      obsDate: usable[0].date,
    };
  }
  // pct_mom
  if (usable[1].value === 0) return empty;
  const actual = (usable[0].value / usable[1].value - 1) * 100;
  const hasPrev = usable.length > 2 && isAdjacent(usable[1], usable[2]) && usable[2].value !== 0;
  const previous = hasPrev ? (usable[1].value / usable[2].value - 1) * 100 : null;
  return {
    actual: `${actual >= 0 ? '+' : ''}${actual.toFixed(1)}`,
    previous: previous === null ? '' : `${previous >= 0 ? '+' : ''}${previous.toFixed(1)}`,
    obsDate: usable[0].date,
  };
}

/**
 * Fill actual/previous on calendar events whose print is available:
 * an event matches when its `event` name has a series mapping and its
 * date is today or earlier (the calendar window starts at today, so in
 * practice this fills print-day rows on the runs after the release).
 *
 * @returns {number} count of events filled.
 */
export function fillEventActuals(events, printsByEvent, todayISO) {
  let filled = 0;
  for (const event of Array.isArray(events) ? events : []) {
    if (event.actual) continue;
    const print = printsByEvent[event.event];
    if (!print || !print.actual) continue;
    if (typeof event.date !== 'string' || event.date > todayISO) continue;
    // Stale-print guard: the observation must belong to the period THIS
    // release reports β€” pre-print on release day, the latest obs is the
    // prior period and must not be presented as today's number.
    const freq = EVENT_SERIES[event.event]?.freq ?? 'm';
    if (!observationMatchesRelease(event.date, print.obsDate, freq)) continue;
    event.actual = print.actual;
    if (!event.previous && print.previous) event.previous = print.previous;
    filled++;
  }
  return filled;
}