fix: FRED data quality — GDP growth %, CPI/PCE YoY%, ICSA in K

- GDPC1 replaces A191RL1Q225SBEA: compute annualized QoQ growth from
  GDP level ((val/prev)^4 - 1)*100 → displays proper ~2-3% not 31 819
- CPIAUCSL/CPILFESL/PCEPILFE: yoy_pct transform (val/val_12m_ago-1)*100
  → displays 3.x% YoY inflation, not raw index level 334
- ICSA: div1000 transform → displays 226 K claims, not 226 000 K
- delta_absolute flag: pp change for rate/% series, % change for levels
- SurprisePct component: shows 'pp' suffix for %, '%' for K/levels
- Column header renamed from 'Δ%' to 'Δ vs préc.' with tooltip
- Deprecated A191RL1Q225SBEA rows cleaned from DB on next bootstrap
- Warm-up periods: 2yr for yoy_pct, 3yr for qoq_annualized, 1yr others

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-26 15:16:44 +02:00
parent 2469b76769
commit d25ca92694
2 changed files with 124 additions and 48 deletions

View File

@@ -4,7 +4,7 @@ from FRED's public CSV endpoint (no API key required) and stores it in
the economic_events table with rolling z-score surprises. the economic_events table with rolling z-score surprises.
""" """
import logging import logging
from datetime import datetime, date from datetime import date
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import httpx import httpx
@@ -13,6 +13,15 @@ import pandas as pd
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Series catalog ──────────────────────────────────────────────────────────── # ── Series catalog ────────────────────────────────────────────────────────────
#
# transform options:
# None — store raw FRED value as-is
# "yoy_pct" — compute year-over-year %: (val / val_12m_ago - 1) * 100
# "qoq_annualized"— compute annualized QoQ growth: ((val/val_prev)^4 - 1)*100
# "div1000" — divide by 1000 (FRED gives raw count, display in K)
#
# delta_absolute: if True, surprise_pct = val - prev (absolute pp change)
# if False, surprise_pct = (val-prev)/|prev| * 100 (% change)
FRED_SERIES: Dict[str, Dict[str, Any]] = { FRED_SERIES: Dict[str, Dict[str, Any]] = {
"PAYEMS": { "PAYEMS": {
@@ -23,6 +32,8 @@ FRED_SERIES: Dict[str, Dict[str, Any]] = {
"higher_is_bullish": True, "higher_is_bullish": True,
"assets": ["SPY", "QQQ", "EURUSD=X", "TLT"], "assets": ["SPY", "QQQ", "EURUSD=X", "TLT"],
"zscore_window": 12, "zscore_window": 12,
"transform": None,
"delta_absolute": False,
}, },
"UNRATE": { "UNRATE": {
"name": "Unemployment Rate", "name": "Unemployment Rate",
@@ -32,33 +43,41 @@ FRED_SERIES: Dict[str, Dict[str, Any]] = {
"higher_is_bullish": False, "higher_is_bullish": False,
"assets": ["SPY", "QQQ", "EURUSD=X"], "assets": ["SPY", "QQQ", "EURUSD=X"],
"zscore_window": 12, "zscore_window": 12,
"transform": None,
"delta_absolute": True, # pp change (e.g. 4.1 → 4.0 = -0.1 pp)
}, },
"CPIAUCSL": { "CPIAUCSL": {
"name": "CPI (All Items YoY)", "name": "CPI All Items (YoY %)",
"unit": "%", "unit": "%",
"freq": "monthly", "freq": "monthly",
"category": "inflation", "category": "inflation",
"higher_is_bullish": False, "higher_is_bullish": False,
"assets": ["TLT", "GLD", "EURUSD=X", "SPY"], "assets": ["TLT", "GLD", "EURUSD=X", "SPY"],
"zscore_window": 12, "zscore_window": 12,
"transform": "yoy_pct", # FRED gives index level → compute YoY
"delta_absolute": True, # pp change between consecutive YoY readings
}, },
"CPILFESL": { "CPILFESL": {
"name": "Core CPI", "name": "Core CPI (YoY %)",
"unit": "%", "unit": "%",
"freq": "monthly", "freq": "monthly",
"category": "inflation", "category": "inflation",
"higher_is_bullish": False, "higher_is_bullish": False,
"assets": ["TLT", "GLD", "EURUSD=X"], "assets": ["TLT", "GLD", "EURUSD=X"],
"zscore_window": 12, "zscore_window": 12,
"transform": "yoy_pct",
"delta_absolute": True,
}, },
"PCEPILFE": { "PCEPILFE": {
"name": "Core PCE", "name": "Core PCE (YoY %)",
"unit": "%", "unit": "%",
"freq": "monthly", "freq": "monthly",
"category": "inflation", "category": "inflation",
"higher_is_bullish": False, "higher_is_bullish": False,
"assets": ["TLT", "GLD", "SPY"], "assets": ["TLT", "GLD", "SPY"],
"zscore_window": 12, "zscore_window": 12,
"transform": "yoy_pct",
"delta_absolute": True,
}, },
"FEDFUNDS": { "FEDFUNDS": {
"name": "Fed Funds Rate (FOMC)", "name": "Fed Funds Rate (FOMC)",
@@ -68,6 +87,8 @@ FRED_SERIES: Dict[str, Dict[str, Any]] = {
"higher_is_bullish": False, "higher_is_bullish": False,
"assets": ["TLT", "SPY", "EURUSD=X", "GLD"], "assets": ["TLT", "SPY", "EURUSD=X", "GLD"],
"zscore_window": 12, "zscore_window": 12,
"transform": None,
"delta_absolute": True, # bp/pp move
}, },
"ICSA": { "ICSA": {
"name": "Initial Jobless Claims", "name": "Initial Jobless Claims",
@@ -77,15 +98,20 @@ FRED_SERIES: Dict[str, Dict[str, Any]] = {
"higher_is_bullish": False, "higher_is_bullish": False,
"assets": ["SPY", "QQQ"], "assets": ["SPY", "QQQ"],
"zscore_window": 52, "zscore_window": 52,
"transform": "div1000", # FRED gives raw count → display in K
"delta_absolute": False,
}, },
"A191RL1Q225SBEA": { "GDPC1": {
"name": "GDP Growth (Quarterly)", # Real GDP level in billions (chained 2017$) → compute QoQ annualized growth
"name": "GDP Growth (QoQ Ann. %)",
"unit": "%", "unit": "%",
"freq": "quarterly", "freq": "quarterly",
"category": "growth", "category": "growth",
"higher_is_bullish": True, "higher_is_bullish": True,
"assets": ["SPY", "QQQ", "EURUSD=X", "TLT"], "assets": ["SPY", "QQQ", "EURUSD=X", "TLT"],
"zscore_window": 8, "zscore_window": 8,
"transform": "qoq_annualized",
"delta_absolute": True, # pp change between quarterly readings
}, },
"BAMLH0A0HYM2": { "BAMLH0A0HYM2": {
"name": "HY Credit Spread (OAS)", "name": "HY Credit Spread (OAS)",
@@ -95,6 +121,8 @@ FRED_SERIES: Dict[str, Dict[str, Any]] = {
"higher_is_bullish": False, "higher_is_bullish": False,
"assets": ["HYG", "LQD", "SPY"], "assets": ["HYG", "LQD", "SPY"],
"zscore_window": 52, "zscore_window": 52,
"transform": None,
"delta_absolute": True, # pp change in spread
}, },
"T10Y2Y": { "T10Y2Y": {
"name": "Yield Spread 10Y-2Y", "name": "Yield Spread 10Y-2Y",
@@ -104,6 +132,8 @@ FRED_SERIES: Dict[str, Dict[str, Any]] = {
"higher_is_bullish": True, "higher_is_bullish": True,
"assets": ["TLT", "IEF", "SPY", "HYG"], "assets": ["TLT", "IEF", "SPY", "HYG"],
"zscore_window": 52, "zscore_window": 52,
"transform": None,
"delta_absolute": True,
}, },
"T10Y3M": { "T10Y3M": {
"name": "Yield Spread 10Y-3M", "name": "Yield Spread 10Y-3M",
@@ -113,9 +143,14 @@ FRED_SERIES: Dict[str, Dict[str, Any]] = {
"higher_is_bullish": True, "higher_is_bullish": True,
"assets": ["TLT", "IEF", "SPY"], "assets": ["TLT", "IEF", "SPY"],
"zscore_window": 52, "zscore_window": 52,
"transform": None,
"delta_absolute": True,
}, },
} }
# Series that were renamed/replaced — will be purged from DB on bootstrap
DEPRECATED_SERIES = ["A191RL1Q225SBEA"]
CATEGORIES = sorted({v["category"] for v in FRED_SERIES.values()}) CATEGORIES = sorted({v["category"] for v in FRED_SERIES.values()})
@@ -124,11 +159,7 @@ CATEGORIES = sorted({v["category"] for v in FRED_SERIES.values()})
_FRED_CSV_URL = "https://fred.stlouisfed.org/graph/fredgraph.csv?id={series_id}" _FRED_CSV_URL = "https://fred.stlouisfed.org/graph/fredgraph.csv?id={series_id}"
def _fetch_fred_csv(series_id: str, from_date: str = "2019-01-01") -> Optional[pd.DataFrame]: def _fetch_fred_csv(series_id: str, from_date: str) -> Optional[pd.DataFrame]:
"""
Download FRED series as CSV. Returns DataFrame with DATE index and VALUE column.
Uses public endpoint — no API key required.
"""
url = _FRED_CSV_URL.format(series_id=series_id) url = _FRED_CSV_URL.format(series_id=series_id)
try: try:
resp = httpx.get(url, timeout=30, follow_redirects=True) resp = httpx.get(url, timeout=30, follow_redirects=True)
@@ -139,20 +170,39 @@ def _fetch_fred_csv(series_id: str, from_date: str = "2019-01-01") -> Optional[p
df["value"] = pd.to_numeric(df["value"], errors="coerce") df["value"] = pd.to_numeric(df["value"], errors="coerce")
df = df.dropna() df = df.dropna()
df = df[df.index >= pd.Timestamp(from_date)] df = df[df.index >= pd.Timestamp(from_date)]
df = df.sort_index() return df.sort_index()
return df
except Exception as e: except Exception as e:
logger.warning(f"[FRED] Failed to fetch {series_id}: {e}") logger.warning(f"[FRED] Failed to fetch {series_id}: {e}")
return None return None
# ── Z-score computation ─────────────────────────────────────────────────────── # ── Transforms ────────────────────────────────────────────────────────────────
def _apply_transform(df: pd.DataFrame, transform: str) -> pd.DataFrame:
"""Apply a series-level transform before storing. Drops NaN rows introduced."""
if transform == "yoy_pct":
# Year-over-year % change from index level
df = df.copy()
df["value"] = (df["value"] / df["value"].shift(12) - 1) * 100
df = df.dropna()
elif transform == "qoq_annualized":
# Annualized quarter-over-quarter growth rate
df = df.copy()
ratio = df["value"] / df["value"].shift(1)
df["value"] = (ratio ** 4 - 1) * 100
df = df.dropna()
elif transform == "div1000":
df = df.copy()
df["value"] = df["value"] / 1000.0
return df
# ── Z-score ───────────────────────────────────────────────────────────────────
def _compute_zscore_series(values: pd.Series, window: int) -> Tuple[pd.Series, pd.Series, pd.Series]: def _compute_zscore_series(values: pd.Series, window: int) -> Tuple[pd.Series, pd.Series, pd.Series]:
""" """
Returns (z_scores, rolling_mean, rolling_std) for a value series. Z-score using previous-window only (no look-ahead).
Z-score = (value - rolling_mean_prev) / rolling_std_prev Z = (value - rolling_mean_prev_N) / rolling_std_prev_N
Uses previous window to avoid look-ahead.
""" """
roll_mean = values.shift(1).rolling(window, min_periods=max(4, window // 3)).mean() roll_mean = values.shift(1).rolling(window, min_periods=max(4, window // 3)).mean()
roll_std = values.shift(1).rolling(window, min_periods=max(4, window // 3)).std() roll_std = values.shift(1).rolling(window, min_periods=max(4, window // 3)).std()
@@ -163,14 +213,10 @@ def _compute_zscore_series(values: pd.Series, window: int) -> Tuple[pd.Series, p
def _direction(zscore: float, higher_is_bullish: bool) -> str: def _direction(zscore: float, higher_is_bullish: bool) -> str:
if abs(zscore) < 0.5: if abs(zscore) < 0.5:
return "neutral" return "neutral"
positive = zscore > 0 return "bullish" if (zscore > 0) == higher_is_bullish else "bearish"
return "bullish" if positive == higher_is_bullish else "bearish"
# ── Downsample for noisy daily/weekly series ──────────────────────────────────
def _resample_weekly(df: pd.DataFrame) -> pd.DataFrame: def _resample_weekly(df: pd.DataFrame) -> pd.DataFrame:
"""Resample to weekly (last value of each week, Friday)."""
return df.resample("W-FRI").last().dropna() return df.resample("W-FRI").last().dropna()
@@ -183,6 +229,7 @@ def bootstrap_fred(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Fetch and store FRED data from from_date to today. Fetch and store FRED data from from_date to today.
Transforms are applied before storing (YoY%, QoQ Ann., /1000).
Returns summary dict with counts per series. Returns summary dict with counts per series.
""" """
from services.database import get_conn from services.database import get_conn
@@ -190,50 +237,73 @@ def bootstrap_fred(
target_series = series_ids or list(FRED_SERIES.keys()) target_series = series_ids or list(FRED_SERIES.keys())
results: Dict[str, Any] = {} results: Dict[str, Any] = {}
conn = get_conn() conn = get_conn()
# Purge deprecated series from DB
for dep in DEPRECATED_SERIES:
try:
conn.execute("DELETE FROM economic_events WHERE series_id=?", (dep,))
except Exception:
pass
conn.commit()
for sid in target_series: for sid in target_series:
meta = FRED_SERIES.get(sid) meta = FRED_SERIES.get(sid)
if not meta: if not meta:
logger.warning(f"[FRED bootstrap] Unknown series: {sid}") logger.warning(f"[FRED bootstrap] Unknown series: {sid}")
continue continue
logger.info(f"[FRED bootstrap] Fetching {sid} ({meta['name']}) from {from_date}") logger.info(f"[FRED bootstrap] Fetching {sid} ({meta['name']})")
# Warm-up period: extra years before from_date for transform + z-score
transform = meta.get("transform")
extra_years = 3 if transform == "qoq_annualized" else (2 if transform == "yoy_pct" else 1)
fetch_from = str(date(int(from_date[:4]) - extra_years, 1, 1))
# Fetch one extra year before from_date for z-score warm-up
fetch_from = str(date(int(from_date[:4]) - 1, 1, 1))
df = _fetch_fred_csv(sid, from_date=fetch_from) df = _fetch_fred_csv(sid, from_date=fetch_from)
if df is None or df.empty: if df is None or df.empty:
results[sid] = {"status": "fetch_failed", "count": 0} results[sid] = {"status": "fetch_failed", "count": 0}
continue continue
# For weekly-sampled continuous series, resample # Apply transform on full (warm-up included) dataset
if transform:
df = _apply_transform(df, transform)
if df.empty:
results[sid] = {"status": "transform_empty", "count": 0}
continue
# Downsample continuous daily/weekly series to weekly
if meta["freq"] == "weekly" and len(df) > 200: if meta["freq"] == "weekly" and len(df) > 200:
df = _resample_weekly(df) df = _resample_weekly(df)
# Z-score on transformed values
window = meta["zscore_window"] window = meta["zscore_window"]
z_series, mean_series, _ = _compute_zscore_series(df["value"], window) z_series, _, _ = _compute_zscore_series(df["value"], window)
delta_absolute = meta.get("delta_absolute", False)
inserted = 0 inserted = 0
skipped = 0 skipped = 0
for dt, row in df.iterrows(): for dt, row in df.iterrows():
ev_date = dt.strftime("%Y-%m-%d") ev_date = dt.strftime("%Y-%m-%d")
# Skip rows before the real from_date (warm-up period)
if ev_date < from_date: if ev_date < from_date:
continue continue # warm-up only, don't store
val = float(row["value"]) val = float(row["value"])
z = z_series.get(dt) z = float(z_series.get(dt) or 0.0)
prev_val = df["value"].shift(1).get(dt) if pd.isna(z):
if z is None or pd.isna(z):
z = 0.0 z = 0.0
z = float(z)
prev = float(prev_val) if prev_val is not None and not pd.isna(prev_val) else None prev_raw = df["value"].shift(1).get(dt)
surprise_pct = round((val - prev) / abs(prev) * 100, 2) if prev and prev != 0 else None prev = float(prev_raw) if prev_raw is not None and not pd.isna(prev_raw) else None
if delta_absolute:
# pp / absolute change (for rates, spreads, YoY series)
surprise_pct = round(val - prev, 4) if prev is not None else None
else:
# % change (for levels: NFP, ICSA in K, etc.)
surprise_pct = round((val - prev) / abs(prev) * 100, 2) if prev and prev != 0 else None
direction = _direction(z, meta["higher_is_bullish"]) direction = _direction(z, meta["higher_is_bullish"])
try: try:
@@ -253,9 +323,8 @@ def bootstrap_fred(
if force else "NOTHING" if force else "NOTHING"
), ),
( (
meta["name"], sid, ev_date, val, meta["unit"], meta["name"], sid, ev_date, round(val, 4), meta["unit"],
None, # no consensus forecast available None, prev, surprise_pct, z, direction,
prev, surprise_pct, z, direction,
json.dumps(meta["assets"]), json.dumps(meta["assets"]),
"FRED/bootstrap", "FRED/bootstrap",
), ),
@@ -270,10 +339,10 @@ def bootstrap_fred(
conn.commit() conn.commit()
results[sid] = { results[sid] = {
"status": "ok", "status": "ok",
"name": meta["name"], "name": meta["name"],
"inserted": inserted, "inserted": inserted,
"skipped": skipped, "skipped": skipped,
"total_fetched": len(df), "total_fetched": len(df[df.index >= pd.Timestamp(from_date)]),
} }
logger.info(f"[FRED bootstrap] {sid}: {inserted} inserted, {skipped} skipped") logger.info(f"[FRED bootstrap] {sid}: {inserted} inserted, {skipped} skipped")

View File

@@ -118,12 +118,19 @@ function DirBadge({ dir }: { dir: string | null }) {
) )
} }
function SurprisePct({ v }: { v: number | null }) { // For rate/spread series (unit % or pp): delta_absolute=true → show absolute pp change
// For level series (K, raw): delta_absolute=false → show relative % change
const DELTA_ABSOLUTE_UNITS = new Set(['%', 'pp'])
function SurprisePct({ v, unit }: { v: number | null; unit: string }) {
if (v === null || v === undefined) return <span className="text-slate-600"></span> if (v === null || v === undefined) return <span className="text-slate-600"></span>
const isPp = DELTA_ABSOLUTE_UNITS.has(unit)
const sign = v > 0 ? '+' : '' const sign = v > 0 ? '+' : ''
const decimals = isPp ? 2 : 1
const suffix = isPp ? ' pp' : '%'
return ( return (
<span className={clsx('text-xs font-mono', v > 0 ? 'text-emerald-400' : v < 0 ? 'text-red-400' : 'text-slate-400')}> <span className={clsx('text-xs font-mono', v > 0 ? 'text-emerald-400' : v < 0 ? 'text-red-400' : 'text-slate-400')}>
{sign}{v.toFixed(1)}% {sign}{v.toFixed(decimals)}{suffix}
</span> </span>
) )
} }
@@ -510,7 +517,7 @@ export default function CalendarPage() {
</th> </th>
<th className="text-right py-1.5 pr-3">Actuel</th> <th className="text-right py-1.5 pr-3">Actuel</th>
<th className="text-right py-1.5 pr-3">Précédent</th> <th className="text-right py-1.5 pr-3">Précédent</th>
<th className="text-right py-1.5 pr-3">Δ%</th> <th className="text-right py-1.5 pr-3" title="Variation vs période précédente (pp pour les séries en %, % pour les niveaux)">Δ vs préc.</th>
<th className="text-right py-1.5 pr-3 cursor-pointer hover:text-white" onClick={() => toggleSort('zscore')}> <th className="text-right py-1.5 pr-3 cursor-pointer hover:text-white" onClick={() => toggleSort('zscore')}>
Z-Score <SortIcon col="zscore" /> Z-Score <SortIcon col="zscore" />
</th> </th>
@@ -542,7 +549,7 @@ export default function CalendarPage() {
{fmt(ev.previous_value)} {fmt(ev.previous_value)}
</td> </td>
<td className="py-1.5 pr-3 text-right"> <td className="py-1.5 pr-3 text-right">
<SurprisePct v={ev.surprise_pct} /> <SurprisePct v={ev.surprise_pct} unit={ev.actual_unit} />
</td> </td>
<td className="py-1.5 pr-3 text-right"> <td className="py-1.5 pr-3 text-right">
<ZBadge z={ev.surprise_zscore} dir={ev.surprise_direction} /> <ZBadge z={ev.surprise_zscore} dir={ev.surprise_direction} />