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>
244 lines
7.8 KiB
Python
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),
|
|
}
|