Files
OpenFin/backend/services/central_bank_fetcher.py
2026-07-14 11:21:43 +02:00

301 lines
10 KiB
Python

"""
Central bank publication tracker — Fed + ECB RSS feeds.
No API key required.
"""
from __future__ import annotations
import logging
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from typing import Any, Dict, List, Optional, Tuple
import requests
logger = logging.getLogger(__name__)
FED_FEEDS: List[Tuple[str, str]] = [
("https://www.federalreserve.gov/feeds/press_monetary.xml", "FOMC/Monetary Policy"),
("https://www.federalreserve.gov/feeds/speeches.xml", "Fed Speeches"),
]
ECB_FEEDS: List[Tuple[str, str]] = [
("https://www.ecb.europa.eu/rss/press.html", "ECB Press"),
("https://www.ecb.europa.eu/press/govcounc/monpol/html/index.en.html", "ECB Monetary Policy"),
]
MONETARY_KEYWORDS = [
"rate", "policy", "inflation", "fomc", "minutes", "statement",
"monetary", "decision", "outlook", "hike", "cut", "pause", "hold",
"employment", "balance sheet", "quantitative",
]
HAWKISH_WORDS = ["hike", "hawkish", "raised", "tightening", "tighter"]
DOVISH_WORDS = ["cut", "dovish", "easing", "lowered", "accommodative"]
_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; OpenFin/1.0)"}
_LOOKBACK_DAYS = 14
_MAX_KEY_POINTS = 8
_ATOM_NS = "http://www.w3.org/2005/Atom"
def _parse_date(raw: Optional[str]) -> Optional[datetime]:
if not raw:
return None
raw = raw.strip()
try:
dt = parsedate_to_datetime(raw)
return dt.astimezone(timezone.utc)
except Exception:
pass
for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d"):
try:
dt = datetime.strptime(raw[: len(fmt)], fmt)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
except ValueError:
continue
logger.debug(f"[CB] Could not parse date: {raw!r}")
return None
def _text(element: Optional[ET.Element]) -> str:
if element is None:
return ""
return (element.text or "").strip()
def _fetch_feed(url: str, label: str) -> List[Dict[str, str]]:
try:
resp = requests.get(url, timeout=15, headers=_HEADERS)
resp.raise_for_status()
except Exception as e:
logger.warning(f"[CB] Failed to fetch feed {label} ({url}): {e}")
return []
try:
root = ET.fromstring(resp.text)
except ET.ParseError as e:
logger.warning(f"[CB] XML parse error for {label}: {e}")
return []
items: List[Dict[str, str]] = []
tag = root.tag
if "Atom" in tag or tag.endswith("}feed"):
for entry in root.findall(f"{{{_ATOM_NS}}}entry"):
title_el = entry.find(f"{{{_ATOM_NS}}}title")
link_el = entry.find(f"{{{_ATOM_NS}}}link")
updated_el = entry.find(f"{{{_ATOM_NS}}}updated")
summary_el = entry.find(f"{{{_ATOM_NS}}}summary")
content_el = entry.find(f"{{{_ATOM_NS}}}content")
link_href = ""
if link_el is not None:
link_href = link_el.get("href", "") or _text(link_el)
description = _text(summary_el) or _text(content_el)
items.append({
"title": _text(title_el),
"link": link_href,
"pub_date_raw": _text(updated_el),
"description": description[:500],
"label": label,
})
else:
channel = root.find("channel") or root
for item in channel.findall("item"):
items.append({
"title": _text(item.find("title")),
"link": _text(item.find("link")),
"pub_date_raw": _text(item.find("pubDate")),
"description": _text(item.find("description"))[:500],
"label": label,
})
logger.info(f"[CB] Fetched {len(items)} items from {label}")
return items
def _is_relevant(title: str, description: str) -> bool:
text = (title + " " + description).lower()
return any(kw in text for kw in MONETARY_KEYWORDS)
def _within_lookback(pub_date_raw: str, days: int = _LOOKBACK_DAYS) -> bool:
dt = _parse_date(pub_date_raw)
if dt is None:
return True
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
return dt >= cutoff
_RATE_RE = re.compile(r"(\d+\.?\d*)\s*%")
_ACTION_RE = re.compile(r"\b(raised|lowered|held\s+steady|unanimous|dissent)\b", re.IGNORECASE)
_TARGET_RE = re.compile(r"\b(2%\s+inflation\s+target|maximum\s+employment)\b", re.IGNORECASE)
_GUIDANCE_RE = re.compile(r"\b(gradual|data[-\s]dependent|patient|vigilant)\b", re.IGNORECASE)
def _extract_key_points(items: List[Dict[str, str]]) -> List[str]:
points: List[str] = []
for item in items:
label_lower = item["label"].lower()
prefix = "[FED]" if ("fed" in label_lower or "fomc" in label_lower) else "[ECB]"
title = item["title"]
combined = f"{title} {item['description']}"
headline = f"{prefix} {title[:100]}"
details: List[str] = []
rate_matches = _RATE_RE.findall(combined)
if rate_matches:
details.append(f"Rate ref: {', '.join(set(rate_matches[:3]))}%")
action = _ACTION_RE.search(combined)
if action:
details.append(action.group(0).lower())
target = _TARGET_RE.search(combined)
if target:
details.append(target.group(0))
guidance = _GUIDANCE_RE.search(combined)
if guidance:
details.append(guidance.group(0).lower())
if details:
headline += f" [{'; '.join(details)}]"
points.append(headline)
if len(points) >= _MAX_KEY_POINTS:
break
return points
def _derive_signals(items: List[Dict[str, str]]) -> Tuple[str, str]:
combined = " ".join(i["title"] for i in items).lower()
hawkish_hit = any(w in combined for w in HAWKISH_WORDS)
dovish_hit = any(w in combined for w in DOVISH_WORDS)
if hawkish_hit and not dovish_hit:
return "bullish", "bearish"
if dovish_hit and not hawkish_hit:
return "bearish", "bullish"
return "neutral", "neutral"
def fetch_central_bank_reports() -> Optional[Dict[str, Any]]:
"""
Fetch Fed + ECB RSS feeds and return a single structured report dict
compatible with the institutional_reports table.
Returns None if no relevant items are found within the lookback window.
"""
all_items: List[Dict[str, str]] = []
for url, label in FED_FEEDS + ECB_FEEDS:
raw_items = _fetch_feed(url, label)
for item in raw_items:
if _within_lookback(item["pub_date_raw"]) and _is_relevant(
item["title"], item["description"]
):
all_items.append(item)
if not all_items:
logger.warning("[CB] No relevant central bank items found in last 14 days")
return None
seen: set = set()
unique_items: List[Dict[str, str]] = []
for item in all_items:
key = item["title"].strip().lower()[:80]
if key not in seen:
seen.add(key)
unique_items.append(item)
report_date = datetime.utcnow().strftime("%Y-%m-%d")
key_points = _extract_key_points(unique_items)
signal_forex, signal_indices = _derive_signals(unique_items)
implications: List[str] = []
combined_titles = " ".join(i["title"] for i in unique_items).lower()
if signal_forex == "bullish":
implications.append(
"Hawkish Fed/ECB rhetoric — supportive of USD strength, headwind for risk assets"
)
elif signal_forex == "bearish":
implications.append(
"Dovish pivot signals — USD pressure, potential tailwind for equities and EM"
)
if "balance sheet" in combined_titles or "quantitative" in combined_titles:
implications.append(
"Balance sheet / QT language present — watch long-end rates and credit spreads"
)
if "inflation" in combined_titles:
implications.append(
"Inflation remains a focal point — monitor breakevens and real yields"
)
if "minutes" in combined_titles:
implications.append(
"FOMC/ECB minutes release — expect intra-day vol spike on language parsing"
)
if not implications:
implications = ["Central bank communication in focus — monitor for forward guidance shifts"]
fed_count = sum(
1 for i in unique_items
if "fed" in i["label"].lower() or "fomc" in i["label"].lower()
)
ecb_count = len(unique_items) - fed_count
source_parts: List[str] = []
if fed_count:
source_parts.append(f"Fed ({fed_count})")
if ecb_count:
source_parts.append(f"ECB ({ecb_count})")
source_label = " + ".join(source_parts) + " via RSS" if source_parts else "Fed/ECB RSS"
importance = 3 if any(
w in combined_titles for w in ["hike", "cut", "minutes", "decision", "statement"]
) else 2
ai_summary = (
f"Central Bank Watch ({report_date}). "
f"{len(unique_items)} relevant items: {source_label}. "
f"USD signal: {signal_forex.upper()}, Indices: {signal_indices.upper()}. "
+ implications[0]
)
return {
"report_type": "central_bank",
"report_date": report_date,
"title": f"Central Bank Monitor — Fed + ECB — {report_date}",
"source": source_label,
"importance": importance,
"category": "macro",
"raw_data": {
"items": [
{
"title": i["title"],
"link": i["link"],
"pub_date": i["pub_date_raw"],
"label": i["label"],
}
for i in unique_items
],
"fed_count": fed_count,
"ecb_count": ecb_count,
},
"key_points": key_points,
"trading_implications": " | ".join(implications),
"signal_energy": "neutral",
"signal_metals": "neutral",
"signal_indices": signal_indices,
"signal_forex": signal_forex,
"ai_summary": ai_summary,
}