""" 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, }