add ECB Data Portal source; group-aware stale thresholds

ECB Statistical Data Warehouse joins as a 5th data source — open API,
no key, daily euro-area yield curve data. Symbol format
'ECB:dataset/series_key', e.g. 'ECB:YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_10Y'
for daily 10y AAA spot rate.

Bonds tab adds ECB EZ 10y AAA + 2y AAA so there's at least some
currently-fresh European sovereign data alongside the US Treasuries.
Country-specific yields (Bund/OAT/BTP/Gilt/JGB) remain on Eurostat/FRED
monthly mirrors — no free daily source exists for those.

Stale threshold is now per-group instead of a flat 90 days. Daily-tape
groups (bonds, rates, equity, etc.) flag stale after a week or three;
monthly groups (economy, macro, valuation) stay at 60-90 days. The
bonds tab will now correctly show 30-60 day-old country yields as
stale next to the daily US/ECB ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-15 23:13:58 +01:00
parent 1edf9cad41
commit 4e7e4981e3
3 changed files with 118 additions and 6 deletions

View file

@ -18,6 +18,7 @@ YAHOO_CHART = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
FRED_API = "https://api.stlouisfed.org/fred/series/observations"
EUROSTAT_API = "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/{dataset}"
ONS_API = "https://www.ons.gov.uk/{topic}/timeseries/{cdid}/{dataset}/data"
ECB_API = "https://data-api.ecb.europa.eu/service/data/{dataset}/{series_key}"
UA = {"User-Agent": "Mozilla/5.0 (cassandra) Python/httpx"}
@ -424,6 +425,93 @@ async def fetch_ons(
return Quote(symbol, "ons", label, note, None, None, None, error=str(e))
# --- ECB Data Portal (no API key, daily euro-area data) ---------------------
async def fetch_ecb(
client: httpx.AsyncClient,
symbol: str,
label: str,
note: str,
anchor: str | None = None,
) -> Quote:
"""Fetch an ECB Statistical Data Warehouse series. `symbol` format:
<dataset>/<series_key>
e.g. 'YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_10Y' for 10y EZ AAA yield (daily).
ECB returns SDMX-JSON; we extract the single observation series."""
try:
if "/" not in symbol:
raise ValueError("ECB symbol must be dataset/series_key")
dataset, series_key = symbol.split("/", 1)
r = await client.get(
ECB_API.format(dataset=dataset, series_key=series_key),
params={"format": "jsondata"},
headers={**UA, "Accept": "application/json"},
timeout=20,
)
r.raise_for_status()
data = r.json()
# SDMX-JSON: dataSets[0].series[key].observations is { "<idx>": [value, ...] }
# where <idx> indexes into structure.dimensions.observation[0].values for time.
ds = (data.get("dataSets") or [{}])[0]
series_map = ds.get("series") or {}
if not series_map:
raise ValueError("no series in response")
ser = next(iter(series_map.values()))
obs = ser.get("observations") or {}
times = [
v["id"]
for v in data["structure"]["dimensions"]["observation"][0]["values"]
]
rows: list[tuple[str, float]] = []
for idx_str in sorted(obs, key=int):
v = obs[idx_str][0]
if v is None:
continue
t = times[int(idx_str)]
iso = _eurostat_time_to_iso(t) # reuse: ECB time codes match Eurostat
try:
rows.append((iso, float(v)))
except (TypeError, ValueError):
continue
if not rows:
raise ValueError("no observations")
last_date, last_val = rows[-1]
def _find_back(min_days: int) -> float | None:
ref = datetime.strptime(last_date, "%Y-%m-%d").date()
for d, v in reversed(rows[:-1]):
if (ref - datetime.strptime(d, "%Y-%m-%d").date()).days >= min_days:
return v
return None
prev_val = rows[-2][1] if len(rows) >= 2 else None
changes = {
"1d": _pct(prev_val, last_val),
"1m": _pct(_find_back(28), last_val),
"1y": _pct(_find_back(360), last_val),
}
anchor_used: str | None = None
if anchor:
anchor_d = _parse_date(anchor).date()
for d, v in reversed(rows):
if datetime.strptime(d, "%Y-%m-%d").date() <= anchor_d:
changes["anchor"] = _pct(v, last_val)
anchor_used = d
break
return Quote(
symbol=symbol, source="ecb", label=label, note=note,
price=last_val, currency=None, as_of=last_date,
changes=changes, anchor_date=anchor_used,
)
except Exception as e:
return Quote(symbol, "ecb", label, note, None, None, None, error=str(e))
# --- Source registry ----------------------------------------------------------
FetcherFn = Callable[..., "Quote"]
@ -432,6 +520,7 @@ SOURCES: dict[str, FetcherFn] = {
"FRED": fetch_fred,
"EUROSTAT": fetch_eurostat,
"ONS": fetch_ons,
"ECB": fetch_ecb,
}