Files
OpenFin/backend/services/te_html_parser.py
OpenSquared 87ded9434f feat: import Trading Economics HTML calendar — 993 events with forecasts
Adds a one-click upload+parse flow for the TE calendar page HTML.
The TE page (tradingeconomics.com/calendar) contains ~1000 events with
actuals, previous, and analyst consensus (forecast) values.

- Add backend/services/te_html_parser.py:
  - parse_html(html): extracts events from <tr data-event> rows
  - Maps 14 countries to major currencies (all Eurozone → EUR)
  - Impact inferred from data-category (high/medium/low)
  - Times converted from Europe/Zurich (CET/CEST) → UTC via zoneinfo
  - Actual=td[5], Forecast=td[7] (analyst consensus), Previous=td[6]
- Add POST /api/eco/te-html-upload (saves file to /tmp)
- Add POST /api/eco/te-html-import + GET /status (background parse)
- Add "Import TE HTML" upload button in CalendarPage ImportPanel

Tested locally: 993 events parsed, 854 with forecast, 861 with actual,
date range 2025-03-31 → 2026-06-17, timezone conversion verified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:15:02 +02:00

244 lines
7.8 KiB
Python

"""
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 <a> + period <span class="calendar-reference">
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] <a class="calendar-event">
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),
}