301 lines
10 KiB
Python
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,
|
|
}
|