""" Parser for Trading Economics economic calendar HTML page. Converts scraped TE HTML → ff_calendar rows (no API key required). Column map in TE HTML table rows: td[0] = time (AM/PM, Europe/Zurich timezone) + date as CSS class td[3] = country code (JP, US, DE, EA, ...) td[4] = event name + period td[5] = actual value td[6] = previous (+ revised if any) td[7] = consensus (analyst forecast — this is what we store as forecast_value) td[8] = TE model forecast (ignored) """ import logging import re from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) # Countries → major currency (Eurozone countries all → EUR) _COUNTRY_TO_CCY: Dict[str, str] = { "united states": "USD", "euro area": "EUR", "germany": "EUR", "france": "EUR", "italy": "EUR", "spain": "EUR", "netherlands": "EUR", "austria": "EUR", "portugal": "EUR", "belgium": "EUR", "greece": "EUR", "finland": "EUR", "ireland": "EUR", "slovakia": "EUR", "united kingdom": "GBP", "japan": "JPY", "china": "CNY", "australia": "AUD", "canada": "CAD", "new zealand": "NZD", "switzerland": "CHF", } # High-impact categories _HIGH_CATS = { "interest rate", "non farm payrolls", "inflation rate", "gdp growth rate", "gdp annual growth rate", "unemployment rate", } # Medium-impact categories _MEDIUM_CATS = { "manufacturing pmi", "non manufacturing pmi", "balance of trade", "retail sales mom", "consumer confidence", "business confidence", "core inflation rate", "core inflation rate mom", "producer price inflation mom", "monthly gdp mom", "job offers", "zew economic sentiment index", } _TZ_ZURICH = None def _get_tz(): global _TZ_ZURICH if _TZ_ZURICH is None: try: from zoneinfo import ZoneInfo _TZ_ZURICH = ZoneInfo("Europe/Zurich") except Exception: import pytz _TZ_ZURICH = pytz.timezone("Europe/Zurich") return _TZ_ZURICH def _parse_time_utc(date_str: str, time_str: str) -> Tuple[str, str]: """ Convert 'YYYY-MM-DD' + '02:30 PM' (Europe/Zurich) → ('YYYY-MM-DD', 'HH:MM') UTC. Falls back to storing the raw time if parsing fails. """ if not time_str or not date_str: return date_str, "00:00" try: tz = _get_tz() dt_local = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %I:%M %p") dt_local = dt_local.replace(tzinfo=tz) dt_utc = dt_local.astimezone(timezone.utc) return dt_utc.strftime("%Y-%m-%d"), dt_utc.strftime("%H:%M") except Exception: return date_str, "00:00" def _clean_value(v: Optional[str]) -> Optional[str]: """Normalize numeric string: strip whitespace/units, return None if empty.""" if not v: return None v = v.strip() if not v or v in ("-", "—", "N/A", ""): return None return v def _infer_impact(category: str) -> str: cat = (category or "").lower().strip() if cat in _HIGH_CATS: return "high" if cat in _MEDIUM_CATS: return "medium" return "low" def parse_html(html: str) -> List[Dict[str, Any]]: """ Parse TE calendar HTML, return list of event dicts ready to upsert. Each dict: event_date, event_time, currency, impact, event_name, actual, forecast, previous """ try: from bs4 import BeautifulSoup except ImportError: raise RuntimeError("beautifulsoup4 not installed — run: pip install beautifulsoup4") soup = BeautifulSoup(html, "html.parser") rows = soup.find_all("tr", attrs={"data-event": True}) logger.info(f"[TE HTML parser] {len(rows)} rows found") events = [] skipped = 0 for row in rows: country = (row.get("data-country") or "").lower().strip() ccy = _COUNTRY_TO_CCY.get(country) if not ccy: skipped += 1 continue category = (row.get("data-category") or "").lower().strip() impact = _infer_impact(category) tds = row.find_all("td") if len(tds) < 8: continue # Date from td[0] CSS class (e.g. "2025-04-01") td0_classes = tds[0].get("class") or [] date_str = "" for c in td0_classes: if re.match(r"\d{4}-\d{2}-\d{2}", c): date_str = c break if not date_str: continue # Time from td[0] text time_raw = tds[0].get_text(separator=" ").strip() time_match = re.search(r"\d{1,2}:\d{2}\s*[AP]M", time_raw, re.I) time_str = time_match.group(0).strip() if time_match else "" ev_date, ev_time = _parse_time_utc(date_str, time_str) # Event name from td[4] ev_link = tds[4].find("a", class_="calendar-event") if ev_link: event_name = ev_link.get_text(strip=True) else: event_name = tds[4].get_text(strip=True) # Strip appended period reference (MAR, Q1, APR, etc.) event_name = re.sub(r"\s+(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC|Q[1-4])$", "", event_name, flags=re.I) event_name = event_name.strip() if not event_name: continue # Values actual = _clean_value(tds[5].get_text(strip=True)) previous_span = tds[6].find("span", id="previous") if previous_span: previous = _clean_value(previous_span.get_text(strip=True)) else: previous = _clean_value(tds[6].get_text(strip=True)) consensus = _clean_value(tds[7].get_text(strip=True)) events.append({ "event_date": ev_date, "event_time": ev_time, "currency": ccy, "impact": impact, "event_name": event_name, "actual": actual, "forecast": consensus, # consensus = analyst forecast "previous": previous, }) logger.info(f"[TE HTML parser] {len(events)} valid events, {skipped} skipped") return events def import_html_file(file_path: str) -> Dict[str, Any]: """ Parse TE calendar HTML file and upsert all events into ff_calendar. Returns summary dict. """ from services.database import get_conn from services.ff_calendar import _upsert_batch, FF_TO_FRED import os if not os.path.exists(file_path): return {"error": f"File not found: {file_path}"} print(f"[TE HTML] Reading {file_path} …", flush=True) with open(file_path, encoding="utf-8", errors="replace") as f: html = f.read() print(f"[TE HTML] File size: {len(html):,} chars", flush=True) events = parse_html(html) if not events: return {"error": "No events parsed — check file format"} batch = [] for ev in events: series_id = FF_TO_FRED.get(ev["event_name"]) batch.append(( ev["event_date"], ev["event_time"], ev["currency"], ev["impact"], ev["event_name"], ev["actual"], ev["forecast"], ev["previous"], series_id, None, "te_html", )) CHUNK = 500 conn = get_conn() for i in range(0, len(batch), CHUNK): _upsert_batch(conn, batch[i:i + CHUNK]) conn.commit() conn.close() by_ccy: Dict[str, int] = {} for ev in events: by_ccy[ev["currency"]] = by_ccy.get(ev["currency"], 0) + 1 print(f"[TE HTML] {len(batch)} events upserted", flush=True) return { "total": len(batch), "by_currency": by_ccy, "date_from": min(ev["event_date"] for ev in events), "date_to": max(ev["event_date"] for ev in events), }