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:
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user