Add official US and European collection and calendar storage
Collect official RSS and Atom summaries, preserve historical captures, and poll macro releases with bounded cadence and Retry-After handling. Separate news collectors from the economic calendar worker. Track statistical periods, publication stages, first observations, revisions, and current official series. Include explicit migration files and isolated archive, parsing, and import tests; no migration or import is executed by this commit. Keep dependency declarations and lockfile together for PDF archives and the upcoming captcha integration. Verify the staged snapshot with 90 focused tests.
This commit is contained in:
1 parent
5fa1e37fd4
commit
e8ca4daa0e
76 files changed
+18923
-4
No files matched your search
@@ -0,0 +1 @@
|
||||
"""美国经济日历:官方日程、公布值及可追溯修订,与新闻发布时间分开。"""
|
||||
@@ -0,0 +1,146 @@
|
||||
"""官方当期公告解析;归档内容不被标为公布时就已采集的原始版本。"""
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from parsel import Selector
|
||||
|
||||
from alphapulse.calendar.parsers import EASTERN, MONTHS, cells, compact, number, parse_values, release_metadata
|
||||
from alphapulse.calendar.umich import pdf_text
|
||||
|
||||
MONTH_PATTERN = "(?:" + "|".join(MONTHS) + ")"
|
||||
MOVEMENT = r"(increased|rose|advanced|edged up|moved up|declined|decreased|fell|edged down|moved down)\s+([\d.]+)\s*percent"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Announcement:
|
||||
at: datetime
|
||||
period: str | None
|
||||
stage: str | None
|
||||
values: dict
|
||||
|
||||
|
||||
def signed(match: re.Match) -> str:
|
||||
sign = "-" if match[1].lower() in {"declined", "decreased", "fell", "edged down", "moved down"} else ""
|
||||
return number(sign + match[2])
|
||||
|
||||
|
||||
def publication_time(text: str) -> datetime:
|
||||
date = re.search(MONTH_PATTERN + r"\s+\d{1,2},\s*20\d\d", text)
|
||||
clock = re.search(r"(\d{1,2}):(\d{2})\s*([ap])\.?m\.?", text, re.I)
|
||||
if date is None or clock is None:
|
||||
raise ValueError("官方公告缺少正式发布日期或公布时刻")
|
||||
day = datetime.strptime(re.sub(r",\s*", ", ", date[0]), "%B %d, %Y")
|
||||
hour = int(clock[1]) % 12 + (12 if clock[3].lower() == "p" else 0)
|
||||
return day.replace(hour=hour, minute=int(clock[2]), tzinfo=EASTERN).astimezone(UTC)
|
||||
|
||||
|
||||
def parse_bls_text(family: str, text: str) -> dict:
|
||||
"""PDF 的表格字体可能无法提取;只读取正文明确叙述的当期读数。"""
|
||||
text = re.sub(r"(?<=\d)\.\s+(?=\d)", ".", compact(text)).replace("−", "-")
|
||||
if family == "jobs":
|
||||
# 与实时 HTML 使用同一就业正文契约,不复制非农单位与负值规则。
|
||||
from html import escape
|
||||
return parse_values(family, '<div id="bodytext">' + escape(text) + '</div>')
|
||||
|
||||
def movement(pattern: str) -> str:
|
||||
match = re.search(pattern, text, re.I)
|
||||
if match is None:
|
||||
raise ValueError(f"BLS {family} 公告缺少明确的当期变化:{pattern}")
|
||||
return signed(match)
|
||||
|
||||
if family == "cpi":
|
||||
monthly = re.search(r"Consumer Price Index for All Urban Consumers.{0,80}?" + MOVEMENT, text, re.I)
|
||||
if monthly:
|
||||
mom = signed(monthly)
|
||||
elif re.search(r"Consumer Price Index for All Urban Consumers.{0,80}?(?:was |remained )?unchanged", text, re.I):
|
||||
mom = "0"
|
||||
else:
|
||||
raise ValueError("CPI 公告缺少整体月率")
|
||||
yoy = movement(r"(?:all items index|index for all items)\s+" + MOVEMENT + r"\s+(?:over|for)\s+the\s+(?:last\s+)?12\s*months")
|
||||
core = re.search(r"(?:index for all items less food and energy|all items less food and energy index)\s+" + MOVEMENT, text, re.I)
|
||||
if core:
|
||||
core_mom = signed(core)
|
||||
elif re.search(r"(?:index for all items less food and energy|all items less food and energy index)\s+(?:was |remained )?unchanged", text, re.I):
|
||||
core_mom = "0"
|
||||
else:
|
||||
raise ValueError("CPI 公告缺少核心月率")
|
||||
core_yoy = movement(r"(?:all items less food and energy index|index for all items less food and energy)\s+" + MOVEMENT + r"\s+(?:over|for)\s+the\s+(?:last\s+)?12\s*months")
|
||||
return {key: {"actual": value, "previous": None} for key, value in
|
||||
[("cpi_mom", mom), ("cpi_yoy", yoy), ("core_cpi_mom", core_mom), ("core_cpi_yoy", core_yoy)]}
|
||||
if family == "ppi":
|
||||
match = re.search(r"Producer Price Index for final demand\s+" + MOVEMENT, text, re.I)
|
||||
if match:
|
||||
mom = signed(match)
|
||||
elif re.search(r"Producer Price Index for final demand\s+(?:was |remained |were )?unchanged", text, re.I):
|
||||
mom = "0"
|
||||
else:
|
||||
raise ValueError("PPI 公告缺少最终需求月率")
|
||||
yoy = movement(r"(?:index for final demand|final demand prices)\s+" + MOVEMENT +
|
||||
r"\s+(?:in\s+20\d\d|(?:for|over)\s+the\s+(?:last\s+)?12[ -]month)")
|
||||
return {"ppi_mom": {"actual": mom, "previous": None}, "ppi_yoy": {"actual": yoy, "previous": None}}
|
||||
raise ValueError(f"不支持的 BLS PDF 指标:{family}")
|
||||
|
||||
|
||||
def parse_announcement(family: str, body: bytes, *, pdf: bool = False) -> Announcement:
|
||||
if pdf:
|
||||
text = compact(pdf_text(body, 1))
|
||||
values = parse_bls_text(family, text)
|
||||
title = text
|
||||
else:
|
||||
try:
|
||||
html = body.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
if family not in {"cpi", "ppi", "jobs"}:
|
||||
raise
|
||||
# 已核实的旧 BLS HTML 混用 Windows-1252 标点;保留原字节,限定此来源解码。
|
||||
html = body.decode("cp1252")
|
||||
sel = Selector(text=html, type="html")
|
||||
text = compact(sel.xpath("string(//body)").get())
|
||||
title = compact(" ".join(sel.css("h1,#bodytext").xpath("string(.)").getall()))
|
||||
if family == "pce" and re.search(r"[A-Z][a-z]+ and [A-Z][a-z]+ 20\d\d", title):
|
||||
raise ValueError("PCE 多统计期合并公告需分别核对各月,未将最后一列冒充全部月份")
|
||||
values = parse_values(family, html)
|
||||
at = publication_time(text)
|
||||
if family in {"cpi", "ppi", "jobs"}:
|
||||
label = {"cpi": "CONSUMER PRICE INDEX", "ppi": "PRODUCER PRICE INDEXES", "jobs": "EMPLOYMENT SITUATION"}[family]
|
||||
heading = re.search(label + r"\s*(?:[—–]|-{1,2})\s*(" + MONTH_PATTERN + r"\s+20\d\d)", title, re.I)
|
||||
period, stage = release_metadata(family, heading[1]) if heading else (None, None)
|
||||
else:
|
||||
period, stage = release_metadata(family, title)
|
||||
if family != "fomc" and period is None:
|
||||
raise ValueError("官方公告缺少统计期")
|
||||
if family == "gdp" and stage is None:
|
||||
raise ValueError("GDP 公告缺少发布阶段")
|
||||
return Announcement(at, period, stage, values)
|
||||
|
||||
|
||||
def parse_announcements(family: str, body: bytes, *, pdf: bool = False) -> list[Announcement]:
|
||||
if family != "pce" or pdf:
|
||||
return [parse_announcement(family, body, pdf=pdf)]
|
||||
sel = Selector(text=body.decode("utf-8"), type="html")
|
||||
title = compact(" ".join(sel.css("h1").xpath("string(.)").getall()))
|
||||
combined = re.search(r"(" + MONTH_PATTERN + r") and (" + MONTH_PATTERN + r") (20\d\d)", title)
|
||||
if combined is None:
|
||||
return [parse_announcement(family, body)]
|
||||
rows = [cells(n) for n in sel.css("table tr")]
|
||||
if ["", combined[1], combined[2]] not in rows:
|
||||
raise ValueError("PCE 合并公告表头未明确列示两个统计月")
|
||||
monthly = {r[0].replace("index,", "index"): r[1:] for r in rows if len(r) == 3}
|
||||
annual = next((compact(p.xpath("string(.)").get()) for p in sel.css(".field--name-body p")
|
||||
if "From the same month one year ago" in p.xpath("string(.)").get()), "")
|
||||
changes = re.findall(r"(increased|decreased|increase|decrease) (?:of )?(\d+(?:\.\d+)?) percent", annual)
|
||||
if len(changes) != 4 or "Excluding food and energy" not in annual:
|
||||
raise ValueError("PCE 合并公告缺少两个统计月的整体和核心年率")
|
||||
# 数值按明确表头分别读取,不把另一统计月标为前次公告值。
|
||||
at = publication_time(compact(sel.xpath("string(//body)").get()))
|
||||
result = []
|
||||
for i, month in enumerate((combined[1], combined[2])):
|
||||
values = {}
|
||||
for key, label in (("pce_mom", "PCE price index"), ("core_pce_mom", "PCE price index excluding food and energy")):
|
||||
values[key] = {"actual": number(monthly[label][i]), "previous": None}
|
||||
for key, offset in (("pce_yoy", 0), ("core_pce_yoy", 2)):
|
||||
movement, value = changes[offset + i]
|
||||
values[key] = {"actual": number(("-" if movement.startswith("decreas") else "") + value), "previous": None}
|
||||
result.append(Announcement(at, f"{combined[3]}-{MONTHS[month]:02d}", None, values))
|
||||
return result
|
||||
@@ -0,0 +1,372 @@
|
||||
"""官方日历历史回补。默认只读预览;--commit 才写库,不启动新闻或交易流程。
|
||||
|
||||
uv run python -m alphapulse.calendar.backfill --start 2025-01-01 [--stored-only] [--commit]
|
||||
按北京时间的公布日期筛选。归档可能已修订,first_received_at 保留真实采集时间,
|
||||
不能把本次回补用作当时已知信息。失败与无公告缺口会写入 JSON 输出并返回非零状态。
|
||||
写入时应暂停 calendar 进程,完成后恢复,避免与日程同步并发创建同一事件。
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
import ssl
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from parsel import Selector
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from alphapulse.calendar.archives import Announcement, parse_announcements
|
||||
from alphapulse.calendar import europe
|
||||
from alphapulse.calendar.catalog import UMICH_CSV, UMICH_HISTORY, UMICH_HOME, UMICH_INFO
|
||||
from alphapulse.calendar.parsers import Scheduled, bea_family, compact
|
||||
from alphapulse.calendar.service import record_revision, save_raw, save_result, save_schedule, save_series
|
||||
from alphapulse.calendar.umich import current_result, document_links, monthly_series, parse_schedule, pdf_text
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.official_sources import HOSTS as EUROPE_HOSTS
|
||||
from alphapulse.models import EconomicRelease, RawBody, RawResponse
|
||||
|
||||
LOCAL = ZoneInfo("Asia/Shanghai")
|
||||
HOSTS = {"bls": {"www.bls.gov", "www.dol.gov"}, "bea": {"www.bea.gov"},
|
||||
"fed": {"www.federalreserve.gov"}, "umich": {"www.sca.isr.umich.edu", "data.sca.isr.umich.edu"}}
|
||||
US_SOURCES = frozenset(HOSTS)
|
||||
HOSTS.update(EUROPE_HOSTS)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Document:
|
||||
source: str
|
||||
url: str
|
||||
body: bytes
|
||||
captured_at: datetime
|
||||
raw_id: int | None = None
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def family_for(document: Document) -> str | None:
|
||||
if document.source in EUROPE_HOSTS:
|
||||
paths = {"ecb": r"/press/pr/date/20\d\d/html/ecb\.mp", "boe": r"/monetary-policy-summary-and-minutes/20\d\d/",
|
||||
"ons": r"/bulletins/", "eurostat": r"/(?:product\?|(?:en/)?web/products-euro-indicators/w/)",
|
||||
"destatis": r"/EN/Press/20\d\d/\d\d/PE", "insee": r"/fr/statistiques/\d+$"}
|
||||
if not re.search(paths[document.source], document.url):
|
||||
return None
|
||||
sel = Selector(text=document.body.decode("utf-8"), type="html")
|
||||
title = compact(" ".join(sel.css("h1").xpath("string(.)").getall()))
|
||||
target = europe.family_for(document.source, title)
|
||||
return target[0] if target else None
|
||||
if document.source == "bls":
|
||||
match = re.search(r"/(cpi|ppi|empsit)[._]", document.url)
|
||||
return {"cpi": "cpi", "ppi": "ppi", "empsit": "jobs"}[match[1]] if match else None
|
||||
if document.source == "bea":
|
||||
sel = Selector(text=document.body.decode("utf-8"), type="html")
|
||||
if any(compact(n.xpath("string(.)").get()).startswith("Personal Income and Outlays, Data Update")
|
||||
for n in sel.css("h1")):
|
||||
return None # 仅修订数据表的通知不是新的当期公布,不回滚原公告。
|
||||
return next((family for n in sel.css("h1") if (family := bea_family(compact(n.xpath("string(.)").get())))), None)
|
||||
if document.source == "fed":
|
||||
return "fomc" if re.search(rb"Federal Reserve issues FOMC statement", document.body) else None
|
||||
return None
|
||||
|
||||
|
||||
def archive_links(source: str, body: str, base: str, start: date, end: date) -> tuple[list[str], str | None]:
|
||||
"""只跟随官方索引的实际链接,不按月份合成公告地址。"""
|
||||
sel = Selector(text=body, type="html")
|
||||
links = []
|
||||
for node in sel.css("a[href]"):
|
||||
url = urljoin(base, node.attrib["href"])
|
||||
path = urlparse(url).path
|
||||
if source == "bls":
|
||||
match = re.search(r"/news.release/(?:archives/)?(?:cpi|ppi|empsit)_(\d{8})\.(?:htm|pdf)$", path)
|
||||
if match and start <= datetime.strptime(match[1], "%m%d%Y").date() <= end:
|
||||
links.append(url)
|
||||
elif source == "bea":
|
||||
match = re.search(r"/news/(20\d\d)/", path)
|
||||
if match and start.year <= int(match[1]) <= end.year and bea_family(compact(node.xpath("string(.)").get())):
|
||||
links.append(url)
|
||||
elif source == "fed":
|
||||
match = re.search(r"/pressreleases/monetary(\d{8})a\.htm$", path)
|
||||
if match and start <= datetime.strptime(match[1], "%Y%m%d").date() <= end:
|
||||
links.append(url)
|
||||
following = sel.css(".pager__item--next a::attr(href)").get() if source == "bea" else None
|
||||
# BEA 按发布时间倒序;整页已早于开始年份后,不再翻阅无关历史。
|
||||
years = [int(y) for y in re.findall(r"/news/(20\d\d)/", body)]
|
||||
if following and years and max(years) < start.year:
|
||||
following = None
|
||||
unique = set(links)
|
||||
# 同一公告优先使用带完整表格的 HTML;无需重复下载、解析同版 PDF。
|
||||
links = [u for u in dict.fromkeys(links) if not u.endswith(".pdf") or u[:-4] + ".htm" not in unique]
|
||||
return links, urljoin(base, following) if following else None
|
||||
|
||||
|
||||
async def ensure_quiet_window() -> None:
|
||||
now = datetime.now(UTC)
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(text("SET TRANSACTION READ ONLY"))
|
||||
imminent = await session.scalar(select(EconomicRelease.id).where(
|
||||
EconomicRelease.schedule_status == "scheduled",
|
||||
EconomicRelease.scheduled_at.between(now - timedelta(minutes=2), now + timedelta(minutes=2)),
|
||||
).limit(1))
|
||||
if imminent is not None:
|
||||
raise RuntimeError("当前处于官方公布前后两分钟窗口,请稍后重跑回补")
|
||||
|
||||
|
||||
async def fetch_documents(start: date, end: date, known: set[str], errors: list[dict], *, sources: set[str] | None = None) -> list[Document]:
|
||||
documents = []
|
||||
selected = sources if sources is not None else US_SOURCES
|
||||
stopped_sources = set()
|
||||
|
||||
def report_error(source: str, url: str, exc: Exception) -> None:
|
||||
failure = {"source": source, "url": url, "error": str(exc)}
|
||||
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in (429, 503):
|
||||
stopped_sources.add(source)
|
||||
failure.update(retry_after=exc.response.headers.get("Retry-After"), stop_reason="已停止该来源,历史覆盖不完整。")
|
||||
errors.append(failure)
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.set_ciphers("DEFAULT")
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=False) as client, httpx.AsyncClient(timeout=15, verify=ctx) as bls:
|
||||
async def fetch(source: str, url: str, *, query: dict | None = None) -> Document:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "https" or parsed.hostname not in HOSTS[source]:
|
||||
raise ValueError("归档链接不是对应机构官方 HTTPS 地址")
|
||||
await ensure_quiet_window()
|
||||
await asyncio.sleep(1)
|
||||
current = url
|
||||
for _ in range(6):
|
||||
if urlparse(current).scheme != "https" or urlparse(current).hostname not in HOSTS[source]:
|
||||
raise ValueError("归档重定向不属于对应机构官方 HTTPS 地址")
|
||||
transport = bls if source == "bls" else client
|
||||
response = await transport.post(current, json=query, headers={"User-Agent": "AlphaPulse calendar history"}) if query is not None else await transport.get(current, headers={"User-Agent": "AlphaPulse calendar history"})
|
||||
if not response.is_redirect:
|
||||
break
|
||||
current = urljoin(current, response.headers["location"])
|
||||
if response.status_code == 303:
|
||||
query = None
|
||||
else:
|
||||
raise ValueError("官方归档重定向超过 5 次")
|
||||
response.raise_for_status()
|
||||
return Document(source, str(response.url), response.content, datetime.now(UTC), headers=dict(response.headers))
|
||||
|
||||
indexes = [("bls", f"https://www.bls.gov/bls/news-release/{name}.htm") for name in ("cpi", "ppi", "empsit")]
|
||||
indexes += [("bea", "https://www.bea.gov/news/archive?field_related_product_target_id=All&created_1=All&title="),
|
||||
("fed", "https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm")]
|
||||
for source, initial in indexes:
|
||||
if source not in selected or source in stopped_sources:
|
||||
continue
|
||||
url = initial
|
||||
visited = set()
|
||||
while url:
|
||||
if url in visited:
|
||||
errors.append({"url": url, "error": "官方分页形成循环"})
|
||||
break
|
||||
visited.add(url)
|
||||
try:
|
||||
index = await fetch(source, url)
|
||||
links, following = archive_links(source, index.body.decode("utf-8"), url, start, end)
|
||||
if not links and url == initial:
|
||||
raise ValueError("官方归档未识别到目标公告,覆盖范围尚未核实")
|
||||
for link in links:
|
||||
if link in known:
|
||||
continue
|
||||
try:
|
||||
documents.append(await fetch(source, link))
|
||||
known.add(link)
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in (429, 503):
|
||||
raise
|
||||
report_error(source, link, exc)
|
||||
url = following
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
report_error(source, str(exc.request.url) if isinstance(exc, httpx.HTTPError) else url, exc)
|
||||
break
|
||||
from alphapulse.calendar.europe_archives import collect
|
||||
for source in sorted(selected & EUROPE_HOSTS.keys()):
|
||||
try:
|
||||
documents += await collect(source, start, end, fetch, known, errors)
|
||||
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
|
||||
report_error(source, str(exc.request.url) if isinstance(exc, httpx.HTTPError) else "", exc)
|
||||
for url in ((UMICH_HISTORY, UMICH_INFO, UMICH_HOME, UMICH_CSV) if "umich" in selected else ()):
|
||||
if "umich" in stopped_sources:
|
||||
break
|
||||
try:
|
||||
doc = await fetch("umich", url)
|
||||
documents.append(doc)
|
||||
if url in (UMICH_HISTORY, UMICH_INFO):
|
||||
labels = ({"Historical Prelim/Final Release Dates"} if url == UMICH_HISTORY else
|
||||
{f"{year} Release Dates" for year in range(start.year, end.year + 1)})
|
||||
links = document_links(doc.body.decode("utf-8"), url, labels)
|
||||
if not links:
|
||||
raise ValueError("密歇根官方索引缺少日程 PDF")
|
||||
for link in links.values():
|
||||
documents.append(await fetch("umich", link))
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
report_error("umich", str(exc.request.url) if isinstance(exc, httpx.HTTPError) else url, exc)
|
||||
return documents
|
||||
|
||||
|
||||
async def save_announcement(session, document: Document, family: str, announcement: Announcement, title: str) -> bool:
|
||||
# 官方公告日期+系列+期次定位已有 ICS 事件,保留其稳定 ID 与首次采集时间。
|
||||
candidates = list(await session.scalars(select(EconomicRelease).where(
|
||||
EconomicRelease.source == document.source, EconomicRelease.family == family,
|
||||
EconomicRelease.scheduled_at == announcement.at,
|
||||
).with_for_update()))
|
||||
candidates = [r for r in candidates if r.period in (None, announcement.period) and r.stage in (None, announcement.stage)]
|
||||
if len(candidates) > 1:
|
||||
raise ValueError("同日同系列匹配到多个日历事件,未写入")
|
||||
raw_id = document.raw_id
|
||||
row = candidates[0] if candidates else None
|
||||
# 回补不回滚已采集的结果;只为已知事件补充缺失的统计期和发布阶段。
|
||||
if row is not None and row.first_received_at is not None:
|
||||
if (row.period is not None or announcement.period is None) and (row.stage is not None or announcement.stage is None):
|
||||
return False
|
||||
if raw_id is None:
|
||||
raw_id = await save_raw(session, document.source, document.url, 200, document.headers, document.body, document.captured_at)
|
||||
row.period, row.stage = announcement.period, announcement.stage
|
||||
row.revision_no += 1
|
||||
record_revision(session, row, raw_id)
|
||||
return True
|
||||
if raw_id is None:
|
||||
raw_id = await save_raw(session, document.source, document.url, 200, document.headers, document.body, document.captured_at)
|
||||
if row is None:
|
||||
row = EconomicRelease(source=document.source, family=family,
|
||||
external_id=f"archive-{family}-{announcement.at:%Y%m%dT%H%M}-{announcement.period}-{announcement.stage}",
|
||||
title=title, period=announcement.period, stage=announcement.stage,
|
||||
scheduled_at=announcement.at, schedule_status="scheduled", schedule_url=document.url,
|
||||
schedule_checked_at=document.captured_at, next_check_at=datetime.now(UTC) + timedelta(days=1),
|
||||
values={}, revision_no=1)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
record_revision(session, row, raw_id)
|
||||
save_result(session, row, announcement.values, document.url, raw_id, document.captured_at,
|
||||
period=announcement.period, stage=announcement.stage)
|
||||
row.error = None
|
||||
return True
|
||||
|
||||
|
||||
async def run(start: date, end: date, *, commit: bool = False, stored_only: bool = False, sources: set[str] | None = None) -> dict:
|
||||
begin = datetime.combine(start, time.min, LOCAL).astimezone(UTC)
|
||||
finish = datetime.combine(end + timedelta(days=1), time.min, LOCAL).astimezone(UTC)
|
||||
errors = []
|
||||
selected = sources if sources is not None else US_SOURCES
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(text("SET TRANSACTION READ ONLY"))
|
||||
rows = (await session.execute(select(RawResponse, RawBody.body).join(RawBody, RawBody.sha256 == RawResponse.body_sha256).where(
|
||||
RawResponse.source_code.in_(selected), RawResponse.status == 200,
|
||||
(RawResponse.url.contains("/news.release/") | RawResponse.url.contains("/newsroom/economicdata/") |
|
||||
RawResponse.url.contains("/news/202") | RawResponse.url.contains("/pressreleases/monetary") |
|
||||
(RawResponse.source_code == "umich") | RawResponse.source_code.in_(EUROPE_HOSTS)),
|
||||
).distinct(RawResponse.source_code, RawResponse.url, RawResponse.body_sha256)
|
||||
.order_by(RawResponse.source_code, RawResponse.url, RawResponse.body_sha256, RawResponse.id))).all()
|
||||
documents = [Document(r.source_code, r.url, gzip.decompress(body), r.fetched_at, r.id, r.headers) for r, body in rows]
|
||||
existing = list(await session.scalars(select(EconomicRelease).where(
|
||||
EconomicRelease.source.in_(selected), EconomicRelease.scheduled_at >= begin, EconomicRelease.scheduled_at < finish)))
|
||||
if not stored_only:
|
||||
documents += await fetch_documents(start, end, {d.url for d in documents}, errors, **({"sources": sources} if sources is not None else {}))
|
||||
schedules: dict[str, tuple[Scheduled, Document]] = {}
|
||||
announcements = []
|
||||
series_points = []
|
||||
# 先处理历史表,再处理年度更正;重跑的稳定身份不依赖日程 PDF 的文件名。
|
||||
parsed_schedules = []
|
||||
for document in (d for d in documents if d.source == "umich" and d.body.startswith(b"%PDF")):
|
||||
try:
|
||||
content = await asyncio.to_thread(pdf_text, document.body)
|
||||
parsed_schedules.append((bool(re.search(r"RELEASE DATES FOR 20\d\d", content)), document,
|
||||
await asyncio.to_thread(parse_schedule, content)))
|
||||
except ValueError as exc:
|
||||
errors.append({"url": document.url, "error": str(exc)})
|
||||
for _, document, events in sorted(parsed_schedules, key=lambda item: (item[0], item[1].captured_at)):
|
||||
for event in events:
|
||||
if begin <= event.at < finish:
|
||||
schedules[event.external_id] = event, document
|
||||
for document in sorted(documents, key=lambda d: d.captured_at):
|
||||
try:
|
||||
if document.source == "umich":
|
||||
if document.url == UMICH_CSV:
|
||||
series_points.append((document, [(p, v) for p, v in monthly_series(document.body.decode())
|
||||
if "2024-12" <= p <= end.strftime("%Y-%m")]))
|
||||
elif document.url.rstrip("/") == UMICH_HOME.rstrip("/"):
|
||||
period, stage, values = current_result(document.body.decode())
|
||||
identity = f"umich-{period}-{stage}"
|
||||
if identity not in schedules:
|
||||
raise ValueError("密歇根当前期次未找到官方公布日程")
|
||||
event, _ = schedules[identity]
|
||||
announcements.append((document, "umich", Announcement(event.at, period, stage, values), event.title))
|
||||
continue
|
||||
family = family_for(document)
|
||||
if family is None:
|
||||
continue
|
||||
parsed = ([await asyncio.to_thread(europe.parse_announcement, family, document.body.decode())]
|
||||
if document.source in EUROPE_HOSTS else
|
||||
await asyncio.to_thread(parse_announcements, family, document.body, pdf=document.body.startswith(b"%PDF")))
|
||||
for announcement in parsed:
|
||||
if begin <= announcement.at < finish:
|
||||
title = compact(" ".join(Selector(text=document.body.decode()).css("h1").xpath("string(.)").getall())) if document.source in EUROPE_HOSTS else family
|
||||
announcements.append((document, family, announcement, title))
|
||||
except (ValueError, KeyError, TypeError, IndexError) as exc:
|
||||
errors.append({"url": document.url, "error": f"{type(exc).__name__}: {exc}"})
|
||||
changed = 0
|
||||
if commit:
|
||||
await ensure_quiet_window()
|
||||
batches = {}
|
||||
for event, document in schedules.values():
|
||||
_, batch = batches.setdefault((document.url, document.captured_at), (document, []))
|
||||
batch.append(event)
|
||||
for document, events in batches.values():
|
||||
async with SessionLocal() as session:
|
||||
raw_id = document.raw_id or await save_raw(session, "umich", document.url, 200, {}, document.body, document.captured_at)
|
||||
await save_schedule(session, "umich", UMICH_INFO, events, raw_id, document.captured_at, withdraw_missing=False)
|
||||
await session.commit()
|
||||
for document, family, announcement, title in announcements:
|
||||
async with SessionLocal() as session:
|
||||
changed += await save_announcement(session, document, family, announcement, title)
|
||||
await session.commit()
|
||||
for document, points in series_points:
|
||||
async with SessionLocal() as session:
|
||||
raw_id = document.raw_id or await save_raw(session, "umich", document.url, 200, {}, document.body, document.captured_at)
|
||||
await save_series(session, "umich", "umich_sentiment", points, "final", raw_id, document.captured_at)
|
||||
await session.commit()
|
||||
actual = {(d.source, f, a.at, a.period, a.stage) for d, f, a, _ in announcements}
|
||||
for row in existing:
|
||||
if row.first_received_at is not None and not any(
|
||||
row.source == s and row.family == f and row.scheduled_at == at
|
||||
and row.period in (None, period) and row.stage in (None, stage) for s, f, at, period, stage in actual):
|
||||
actual.add((row.source, row.family, row.scheduled_at, row.period, row.stage))
|
||||
expected = {(r.source, r.family, r.scheduled_at, r.period, r.stage) for r in existing if r.schedule_status != "withdrawn"}
|
||||
expected.update(("umich", "umich", e.at, e.period, e.stage) for e, _ in schedules.values())
|
||||
missing = [dict(source=s, family=f, at=at.isoformat(), period=p, stage=stage)
|
||||
for s, f, at, p, stage in sorted(expected - actual, key=lambda x: (x[2], x[1]))
|
||||
if not any(s == x[0] and f == x[1] and at == x[2] and p in (None, x[3]) and stage in (None, x[4]) for x in actual)]
|
||||
return {"mode": "commit" if commit else "dry-run", "start": str(start), "end": str(end),
|
||||
"sources": sorted(selected),
|
||||
"archive_coverage": "未在线核对" if stored_only else "见 errors;不能仅凭已存日程判定完整",
|
||||
"documents": len(documents), "announcements": dict(Counter(f for _, f, _, _, _ in actual)),
|
||||
"changed_announcements": changed, "umich_schedule_events": len(schedules),
|
||||
"current_series_points": len(series_points[-1][1]) if series_points else 0,
|
||||
"unavailable_values": [dict(source=d.source, family=f, period=a.period, indicator=k, reason=v["unavailable_reason"])
|
||||
for d, f, a, _ in announcements for k, v in a.values.items() if v.get("unavailable_reason")],
|
||||
"missing_announcements": missing, "errors": errors}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--start", type=date.fromisoformat, default=date(2025, 1, 1))
|
||||
parser.add_argument("--end", type=date.fromisoformat, default=datetime.now(LOCAL).date())
|
||||
parser.add_argument("--stored-only", action="store_true", help="仅重放已存官方响应,不联网核对归档覆盖")
|
||||
parser.add_argument("--source", action="append", choices=sorted(HOSTS), help="只处理指定来源,可重复;默认保留原美国范围,欧洲须显式指定")
|
||||
parser.add_argument("--commit", action="store_true", help="显式写库;运行前须确认目标库并完成迁移")
|
||||
args = parser.parse_args()
|
||||
if args.start < date(2025, 1, 1) or args.end < args.start or args.end > datetime.now(LOCAL).date():
|
||||
parser.error("公布日期范围必须从 2025-01-01 起,结束日期不得晚于今天")
|
||||
report = asyncio.run(run(args.start, args.end, commit=args.commit, stored_only=args.stored_only, sources=set(args.source) if args.source else None))
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
if report["errors"] or report["missing_announcements"]:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Indicator:
|
||||
name: str
|
||||
importance: int
|
||||
unit: str = "%"
|
||||
rate_channel: Literal["inverse", "ambiguous"] = "ambiguous"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Family:
|
||||
country_code: str
|
||||
source: str
|
||||
indicators: tuple[str, ...]
|
||||
|
||||
|
||||
COUNTRIES = {"US": "美国", "EA": "欧元区", "GB": "英国", "DE": "德国", "FR": "法国"}
|
||||
|
||||
|
||||
INDICATORS = {
|
||||
# 项目分级,不冒充第三方星级;rate_channel 仅描述利率传导下的参考方向。
|
||||
"cpi_mom": Indicator("CPI 月率", 4, rate_channel="inverse"),
|
||||
"cpi_yoy": Indicator("CPI 年率", 5, rate_channel="inverse"),
|
||||
"core_cpi_mom": Indicator("核心 CPI 月率", 5, rate_channel="inverse"),
|
||||
"core_cpi_yoy": Indicator("核心 CPI 年率", 5, rate_channel="inverse"),
|
||||
"ppi_mom": Indicator("PPI 最终需求月率", 3, rate_channel="inverse"),
|
||||
"ppi_yoy": Indicator("PPI 最终需求年率", 3, rate_channel="inverse"),
|
||||
"payrolls": Indicator("非农就业新增", 5, "万人"),
|
||||
"unemployment": Indicator("失业率", 4),
|
||||
"pce_mom": Indicator("PCE 物价月率", 3, rate_channel="inverse"),
|
||||
"core_pce_mom": Indicator("核心 PCE 物价月率", 4, rate_channel="inverse"),
|
||||
"pce_yoy": Indicator("PCE 物价年率", 4, rate_channel="inverse"),
|
||||
"core_pce_yoy": Indicator("核心 PCE 物价年率", 5, rate_channel="inverse"),
|
||||
"gdp": Indicator("实际 GDP 年化季率", 4),
|
||||
"fed_rate": Indicator("联邦基金利率目标上限", 5, rate_channel="inverse"),
|
||||
"umich_sentiment": Indicator("密歇根大学消费者信心指数", 3, "点"),
|
||||
}
|
||||
FAMILIES = {
|
||||
"cpi": Family("US", "bls", ("cpi_mom", "cpi_yoy", "core_cpi_mom", "core_cpi_yoy")),
|
||||
"ppi": Family("US", "bls", ("ppi_mom", "ppi_yoy")),
|
||||
"jobs": Family("US", "bls", ("payrolls", "unemployment")),
|
||||
"pce": Family("US", "bea", ("pce_mom", "pce_yoy", "core_pce_mom", "core_pce_yoy")),
|
||||
"gdp": Family("US", "bea", ("gdp",)),
|
||||
"fomc": Family("US", "fed", ("fed_rate",)),
|
||||
"umich": Family("US", "umich", ("umich_sentiment",)),
|
||||
}
|
||||
STAGES = {"preliminary": "初值", "final": "终值", "initial": "首发值", "updated": "更新估计", "advance": "初值", "second": "二次估计", "third": "终值"}
|
||||
STAGES["flash"] = "快报值"
|
||||
|
||||
# 欧洲指标保留自己的口径,不复用美国年化 GDP 或美国通胀的方向标签。
|
||||
INDICATORS.update({
|
||||
"eu_cpi_mom": Indicator("CPI 月率", 4),
|
||||
"eu_cpi_yoy": Indicator("CPI 年率", 4),
|
||||
"eu_core_cpi_yoy": Indicator("核心 CPI 年率", 4),
|
||||
"hicp_mom": Indicator("HICP 月率", 4),
|
||||
"hicp_yoy": Indicator("HICP 年率", 4),
|
||||
"core_hicp_yoy": Indicator("核心 HICP 年率", 4),
|
||||
"gdp_qoq": Indicator("实际 GDP 季率", 4),
|
||||
"gdp_yoy": Indicator("实际 GDP 年率", 4),
|
||||
"ilo_unemployment": Indicator("ILO 失业率", 3),
|
||||
"uk_unemployment": Indicator("ILO 失业率(三个月)", 3),
|
||||
"ecb_deposit": Indicator("存款便利利率", 5),
|
||||
"ecb_main": Indicator("主要再融资利率", 5),
|
||||
"ecb_marginal": Indicator("边际贷款利率", 5),
|
||||
"bank_rate": Indicator("英国央行基准利率", 5),
|
||||
})
|
||||
FAMILIES.update({
|
||||
"ecb_rate": Family("EA", "ecb", ("ecb_deposit", "ecb_main", "ecb_marginal")),
|
||||
"ea_hicp": Family("EA", "eurostat", ("hicp_yoy", "hicp_mom", "core_hicp_yoy")),
|
||||
"ea_gdp": Family("EA", "eurostat", ("gdp_qoq", "gdp_yoy")),
|
||||
"ea_jobs": Family("EA", "eurostat", ("ilo_unemployment",)),
|
||||
"boe_rate": Family("GB", "boe", ("bank_rate",)),
|
||||
"uk_cpi": Family("GB", "ons", ("eu_cpi_yoy", "eu_cpi_mom", "eu_core_cpi_yoy")),
|
||||
"uk_gdp": Family("GB", "ons", ("gdp_qoq", "gdp_yoy")),
|
||||
"uk_jobs": Family("GB", "ons", ("uk_unemployment",)),
|
||||
"de_cpi": Family("DE", "destatis", ("eu_cpi_yoy", "eu_cpi_mom", "hicp_yoy", "hicp_mom")),
|
||||
"de_gdp": Family("DE", "destatis", ("gdp_qoq", "gdp_yoy")),
|
||||
"de_jobs": Family("DE", "destatis", ("ilo_unemployment",)),
|
||||
"fr_cpi": Family("FR", "insee", ("eu_cpi_yoy", "eu_cpi_mom", "hicp_yoy", "hicp_mom")),
|
||||
"fr_gdp": Family("FR", "insee", ("gdp_qoq", "gdp_yoy")),
|
||||
"fr_jobs": Family("FR", "insee", ("ilo_unemployment",)),
|
||||
})
|
||||
UMICH_HOME = "https://www.sca.isr.umich.edu/"
|
||||
UMICH_HISTORY = "https://data.sca.isr.umich.edu/technical-docs.php"
|
||||
UMICH_INFO = "https://data.sca.isr.umich.edu/survey-info.php"
|
||||
UMICH_CSV = "https://www.sca.isr.umich.edu/files/tbmics.csv"
|
||||
SCHEDULE_URLS = {
|
||||
"bls": "https://www.bls.gov/schedule/news_release/bls.ics",
|
||||
"bea": "https://www.bea.gov/news/schedule/ics/online-calendar-subscription.ics",
|
||||
"fed": "https://www.federalreserve.gov/newsevents/calendar.htm",
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
"""欧洲官方日程与公告。数值只取正式公告,当前修订序列不冒充当期结果。"""
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from urllib.parse import urljoin
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from parsel import Selector
|
||||
|
||||
from alphapulse.calendar.archives import Announcement
|
||||
from alphapulse.calendar.catalog import FAMILIES
|
||||
from alphapulse.calendar.parsers import MONTHS, Scheduled, compact
|
||||
from alphapulse.core.official_sources import official_url
|
||||
|
||||
SCHEDULES = {
|
||||
"ecb": "https://www.ecb.europa.eu/press/calendars/mgcgc/html/index.en.html",
|
||||
"eurostat": "https://ec.europa.eu/eurostat/o/calendars/eventsJson",
|
||||
"boe": "https://www.bankofengland.co.uk/monetary-policy/upcoming-mpc-dates",
|
||||
"ons": "https://www.ons.gov.uk/releasecalendar?release-type=type-upcoming&limit=100",
|
||||
"destatis": "https://www.destatis.de/SiteGlobals/Forms/Suche/Termine/EN/Terminsuche_Formular.html",
|
||||
"insee": "https://www.insee.fr/fr/agenda-diffusion",
|
||||
}
|
||||
INSEE_QUERY = {"q": "*:*", "start": 0, "rows": 100, "filters": [],
|
||||
"sortFields": [{"field": "dateEmbargo_dt", "order": "asc"}]}
|
||||
ZONES = {"ecb": "Europe/Berlin", "eurostat": "Europe/Luxembourg", "boe": "Europe/London",
|
||||
"ons": "Europe/London", "destatis": "Europe/Berlin", "insee": "Europe/Paris"}
|
||||
N = r"[+−–‑-]?\d+(?:[.,]\d+)?"
|
||||
FRENCH_MONTHS = dict(zip(("janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"), range(1, 13), strict=True))
|
||||
MONTH_NUMBERS = {**{k.lower(): v for k, v in MONTHS.items()}, **FRENCH_MONTHS}
|
||||
|
||||
|
||||
def local_at(source: str, day: date, clock: str) -> datetime:
|
||||
return datetime.combine(day, time.fromisoformat(clock), ZoneInfo(ZONES[source])).astimezone(UTC)
|
||||
|
||||
|
||||
def numeric(value: str) -> str:
|
||||
value = compact(value).replace(",", ".").replace("−", "-").replace("–", "-").replace("‑", "-").rstrip("% ")
|
||||
if not re.fullmatch(r"[+-]?\d+(?:\.\d+)?", value):
|
||||
raise ValueError(f"官方单元格不是十进制数值:{value}")
|
||||
return str(Decimal(value).normalize()) if Decimal(value) else "0"
|
||||
|
||||
|
||||
def value(actual: str, previous: str | None = None) -> dict:
|
||||
return {"actual": numeric(actual), "previous": numeric(previous) if previous is not None else None}
|
||||
|
||||
|
||||
def match_value(pattern: str, text: str) -> dict:
|
||||
match = re.search(pattern, text, re.I)
|
||||
if match is None:
|
||||
raise ValueError(f"官方公告未识别到数值:{pattern}")
|
||||
return value(match[1])
|
||||
|
||||
|
||||
def period_of(text: str, *, quarter: bool = False) -> str:
|
||||
text = compact(text).lower()
|
||||
if quarter:
|
||||
patterns = (r"q([1-4])\s*[/ -]?\s*(20\d\d)", r"([1-4])(?:st|nd|rd|th|er|e)?\.?\s*(?:quarter|quartal|trimestre)(?: of)?\s+(20\d\d)")
|
||||
for pattern in patterns:
|
||||
if m := re.search(pattern, text):
|
||||
return f"{m[2]}-Q{m[1]}"
|
||||
m = re.search(r"(first|second|third|fourth|premier|deuxième|troisième|quatrième)\s+(?:quarter|trimestre)(?: of)?\s+(20\d\d)", text)
|
||||
if m:
|
||||
q = {"first": 1, "second": 2, "third": 3, "fourth": 4, "premier": 1, "deuxième": 2, "troisième": 3, "quatrième": 4}[m[1]]
|
||||
return f"{m[2]}-Q{q}"
|
||||
# ONS 的季度公告以月份范围命名;末月必须是季度末。
|
||||
months = list(re.finditer(r"(" + "|".join(MONTH_NUMBERS) + r")\s+(20\d\d)", text))
|
||||
if months and (month := MONTH_NUMBERS[months[-1][1]]) in (3, 6, 9, 12):
|
||||
return f"{months[-1][2]}-Q{month // 3}"
|
||||
else:
|
||||
matches = list(re.finditer(r"(" + "|".join(MONTH_NUMBERS) + r")\s+(20\d\d)", text))
|
||||
if matches:
|
||||
m = matches[-1]
|
||||
return f"{m[2]}-{MONTH_NUMBERS[m[1]]:02d}"
|
||||
raise ValueError(f"官方内容未识别到统计期:{text[:160]}")
|
||||
|
||||
|
||||
def family_for(source: str, title: str) -> tuple[str, str | None] | None:
|
||||
t = compact(title).casefold()
|
||||
if source == "ecb":
|
||||
return ("ecb_rate", None) if t == "monetary policy decisions" else None
|
||||
if source == "boe":
|
||||
return ("boe_rate", None) if "bank rate" in t or "mpc summary and minutes" in t else None
|
||||
if source == "eurostat":
|
||||
if "inflation" in t or "hicp" in t:
|
||||
return "ea_hicp", "flash" if "flash" in t or "expected" in t else "final"
|
||||
if "unemployment" in t:
|
||||
return "ea_jobs", None
|
||||
if re.search(r"\bgdp\b", t) and not any(x in t for x in ("deficit", "debt", "update")):
|
||||
return "ea_gdp", "preliminary" if "preliminary" in t else "flash" if "flash" in t else "final"
|
||||
if source == "ons":
|
||||
if "time series" in t:
|
||||
return None
|
||||
if t.startswith("consumer price inflation, uk:"):
|
||||
return "uk_cpi", None
|
||||
if t.startswith(("labour market overview, uk:", "uk labour market:")):
|
||||
return "uk_jobs", None
|
||||
if t.startswith("gdp first quarterly estimate, uk:"):
|
||||
return "uk_gdp", "initial"
|
||||
if t.startswith("gdp quarterly national accounts, uk:"):
|
||||
return "uk_gdp", "final"
|
||||
if source == "destatis":
|
||||
t = t.removeprefix("press ") # 英文公报 h1 包含栏目标签 Press。
|
||||
if ("consumer price" in t and "harmonised" not in t[:12]) or "inflation rate" in t:
|
||||
return "de_cpi", "preliminary" if "preliminary" in t or "expected" in t else "final"
|
||||
if "gross domestic product" in t:
|
||||
return "de_gdp", "initial" if "first release" in t else "final"
|
||||
if "monthly labour market" in t or t.startswith("employment in "):
|
||||
return "de_jobs", None
|
||||
if source == "insee":
|
||||
if "prix à la consommation" in t:
|
||||
return "fr_cpi", "preliminary" if "provisoir" in t else "final"
|
||||
if "comptes nationaux trimestriels" in t:
|
||||
return "fr_gdp", "initial" if "première estimation" in t else "final"
|
||||
if "chômage" in t and ("bit" in t or "taux de chômage" in t):
|
||||
return "fr_jobs", None
|
||||
return None
|
||||
|
||||
|
||||
def event(family: str, title: str, at: datetime, period: str | None, stage: str | None, *, url: str | None = None) -> Scheduled:
|
||||
identity = f"{family}-{period or at.date().isoformat()}-{stage or 'release'}"
|
||||
return Scheduled(identity, family, title, at, period=period, stage=stage, release_url=url)
|
||||
|
||||
|
||||
def eurostat_calendar_url(now: datetime) -> str:
|
||||
from urllib.parse import urlencode
|
||||
zone = ZoneInfo(ZONES["eurostat"])
|
||||
start = (now - timedelta(days=45)).astimezone(zone).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = (now + timedelta(days=90)).astimezone(zone).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return SCHEDULES["eurostat"] + "?" + urlencode({"start": start.isoformat(), "end": end.isoformat(), "theme": "", "category": "", "keywords": "", "isEuroindicator": "true", "authorInclude": "", "authorExclude": ""})
|
||||
|
||||
|
||||
def parse_schedule(source: str, body: str) -> list[Scheduled]:
|
||||
results = []
|
||||
s = Selector(text=body, type="html") if source not in ("eurostat", "insee") else None
|
||||
if source == "ecb":
|
||||
for node in s.css("dt"):
|
||||
title = compact(node.xpath("string(following-sibling::dd[1])").get() or "")
|
||||
if "monetary policy meeting" in title and "non-monetary" not in title and "Day 2" in title:
|
||||
day = datetime.strptime(compact(node.xpath("string(.)").get()), "%d/%m/%Y").date()
|
||||
results.append(event("ecb_rate", title, local_at(source, day, "14:15"), None, None))
|
||||
elif source == "boe":
|
||||
for table in s.css(".page-content table"):
|
||||
heading = compact(table.xpath("string(preceding::h2[1])").get() or "")
|
||||
year = re.search(r"20\d\d", heading)
|
||||
if not year:
|
||||
raise ValueError("英国央行日程缺少年份")
|
||||
for tr in table.css("tr"):
|
||||
label = compact(tr.css("td").xpath("string(.)").get() or "")
|
||||
if not label:
|
||||
continue
|
||||
day = datetime.strptime(label + " " + year[0], "%A %d %B %Y").date()
|
||||
link = tr.css('a[href*="monetary-policy-summary-and-minutes"]::attr(href)').get()
|
||||
url = official_url(source, urljoin(SCHEDULES[source], link)) if link else None
|
||||
results.append(event("boe_rate", "Bank Rate decision", local_at(source, day, "12:00"), None, None, url=url))
|
||||
elif source == "eurostat":
|
||||
data = json.loads(body)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("Eurostat 日程不是事件列表")
|
||||
for item in data:
|
||||
target = family_for(source, item["title"])
|
||||
if target is None or not any(t["label"] == "Euro indicators release" for t in item["types"]):
|
||||
continue
|
||||
family, stage = target
|
||||
# 官方 allDay 日程的 11:00Z 是日历占位时间。实际公告在卢森堡 11:00 发布,
|
||||
# 已核对公告 article:published_time(夏令时 09:00Z),不能按占位 Z 推迟两小时。
|
||||
at = local_at(source, date.fromisoformat(item["start"][:10]), "11:00") if item["allDay"] else datetime.fromisoformat(item["start"]).astimezone(UTC)
|
||||
results.append(event(family, item["title"], at, period_of(item["period"], quarter=family == "ea_gdp"), stage))
|
||||
elif source == "ons":
|
||||
for link in s.css("a[data-gtm-release-title]"):
|
||||
title = compact(Selector(text=link.attrib["data-gtm-release-title"], type="html").xpath("string(.)").get())
|
||||
target = family_for(source, title)
|
||||
if target is None:
|
||||
continue
|
||||
family, stage = target
|
||||
day = datetime.strptime(link.attrib["data-gtm-release-date"], "%Y%m%d").date()
|
||||
at = local_at(source, day, link.attrib["data-gtm-release-time"])
|
||||
period = None if family == "uk_jobs" else period_of(title, quarter=family == "uk_gdp")
|
||||
results.append(event(family, title, at, period, stage, url=official_url(source, urljoin(SCHEDULES[source], link.attrib["href"]))))
|
||||
elif source == "destatis":
|
||||
for node in s.css(".c-result--event-preview"):
|
||||
title = compact(node.css("h3").xpath("string(.)").get())
|
||||
target = family_for(source, title)
|
||||
if target is None:
|
||||
continue
|
||||
family, stage = target
|
||||
details = compact(node.xpath("string(.)").get())
|
||||
family, stage = family_for(source, details)
|
||||
m = re.search(r"Date of issue\s*:\s*(\d{4}\.\d{2}\.\d{2})", details)
|
||||
if m is None:
|
||||
raise ValueError("Destatis 日程缺少正式日期")
|
||||
day = datetime.strptime(m[1], "%Y.%m.%d").date()
|
||||
p = re.search(r"Reporting period\s*:\s*(.*?)\s*Date of issue", details)
|
||||
if p is None:
|
||||
raise ValueError("Destatis 日程缺少统计期")
|
||||
# ICS 明确采用零点占位。CPI 初值为 14:00,其余为官网声明的 08:00。
|
||||
clock = "14:00" if family == "de_cpi" and stage == "preliminary" else "08:00"
|
||||
results.append(event(family, title, local_at(source, day, clock), period_of(p[1], quarter=family == "de_gdp"), stage))
|
||||
elif source == "insee":
|
||||
data = json.loads(body)
|
||||
if data["status"] != 0:
|
||||
raise ValueError("INSEE 官方日程查询失败")
|
||||
for item in data["documents"]:
|
||||
title = item["famille"].get("facetteConjoncture", {}).get("libelleFr", "")
|
||||
target = family_for(source, title)
|
||||
if target is None:
|
||||
continue
|
||||
family, stage = target
|
||||
at = datetime.fromisoformat(item["embargo"]).astimezone(UTC)
|
||||
if datetime.fromisoformat(item["embargo"]).tzinfo is None:
|
||||
raise ValueError("INSEE 日程缺少时区")
|
||||
results.append(event(family, title, at, period_of(item["periodeReferenceEn"], quarter=family in ("fr_gdp", "fr_jobs")), stage,
|
||||
url=f"https://www.insee.fr/fr/statistiques/{item['id']}"))
|
||||
else:
|
||||
raise ValueError(f"未支持的欧洲日程来源:{source}")
|
||||
if not results:
|
||||
raise ValueError(f"{source} 官方日程未识别到目标事件")
|
||||
return results
|
||||
|
||||
|
||||
def table_rows(s: Selector) -> list[list[str]]:
|
||||
return [[compact(c.xpath("string(.)").get()) for c in tr.css("th,td")] for tr in s.css("table tr")]
|
||||
|
||||
|
||||
def row_named(rows: list[list[str]], label: str) -> list[str]:
|
||||
matches = [row for row in rows if row and compact(row[0]).casefold() == label.casefold()]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"官方表格行不唯一或缺失:{label}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def eurostat_row(s: Selector, label: str) -> list[str]:
|
||||
# Eurostat 的数据标志 e/p 紧贴数值书写;仅在这一来源的表格边界剥离。
|
||||
return [re.sub(r"^(" + N + r")\s*[ep]$", r"\1", cell) for cell in row_named(table_rows(s), label)]
|
||||
|
||||
|
||||
def publication(source: str, s: Selector, text: str, family: str, stage: str | None) -> datetime:
|
||||
if source == "eurostat":
|
||||
at = datetime.fromisoformat(s.css('meta[property="article:published_time"]::attr(content)').get() or "")
|
||||
if at.tzinfo is None:
|
||||
raise ValueError("Eurostat 公告时间缺少时区")
|
||||
return at.astimezone(UTC)
|
||||
if source == "boe":
|
||||
label = compact(s.css(".published-date").xpath("string(.)").get() or "")
|
||||
m = re.search(r"Published on\s+(\d{1,2} [A-Z][a-z]+ 20\d\d)", label)
|
||||
if m is None:
|
||||
raise ValueError("英国央行公告缺少本文正式发布日期")
|
||||
day = datetime.strptime(m[1], "%d %B %Y").date()
|
||||
return local_at(source, day, "12:00")
|
||||
if source == "insee":
|
||||
first = s.css(".date-diffusion").xpath("string(.)").get() or ""
|
||||
m = re.search(r"\d{2}/\d{2}/\d{4}", first)
|
||||
if m is None:
|
||||
raise ValueError("INSEE 公告缺少正式发布日期")
|
||||
clock = "07:30" if family == "fr_jobs" or (family == "fr_gdp" and stage == "initial") else "08:45"
|
||||
return local_at(source, datetime.strptime(m[0], "%d/%m/%Y").date(), clock)
|
||||
pattern = {"ecb": r"Monetary policy decisions\s*(\d{1,2} [A-Z][a-z]+ 20\d\d)",
|
||||
"ons": r"Release date:\s*(\d{1,2} [A-Z][a-z]+ 20\d\d)",
|
||||
"destatis": r"Press release No\. \d+ of (\d{1,2} [A-Z][a-z]+ 20\d\d)"}[source]
|
||||
m = re.search(pattern, text)
|
||||
if m is None:
|
||||
raise ValueError(f"{source} 公告缺少正式发布日期")
|
||||
day = datetime.strptime(m[1], "%d %B %Y").date()
|
||||
clock = "14:15" if source == "ecb" else "07:00" if source == "ons" else "14:00" if family == "de_cpi" and stage == "preliminary" else "08:00"
|
||||
return local_at(source, day, clock)
|
||||
|
||||
|
||||
def parse_announcement(family: str, body: str) -> Announcement:
|
||||
source = FAMILIES[family].source
|
||||
s = Selector(text=body, type="html")
|
||||
if source == "eurostat":
|
||||
# 官方表格用上标 e/p 表示估计/临时数据;阶段另存,不把上标并入数值。
|
||||
for node in s.css("table sup"):
|
||||
node.root.getparent().remove(node.root)
|
||||
for node in s.css("script,style,nav,footer,header"):
|
||||
node.root.getparent().remove(node.root)
|
||||
text = compact(" ".join(s.xpath("//body//text() | //main//text()").getall()))
|
||||
title = compact(" ".join(s.css("h1").xpath("string(.)").getall()))
|
||||
rows = table_rows(s)
|
||||
stage = None
|
||||
period = None
|
||||
values = {}
|
||||
if family == "ecb_rate":
|
||||
m = re.search(r"deposit facility, the main refinancing operations and the marginal lending facility[^.]*?(?:to|at)\s*(" + N + r")%,\s*(" + N + r")% and\s*(" + N + r")%", text)
|
||||
if m is None:
|
||||
raise ValueError("ECB 公告缺少三项明确政策利率")
|
||||
values = {key: value(m[i]) for i, key in enumerate(FAMILIES[family].indicators, 1)}
|
||||
elif family == "boe_rate":
|
||||
values["bank_rate"] = match_value(r"Bank [Rr]ate (?:maintained at|reduced to|increased to)\s*(" + N + r")%", title)
|
||||
elif source == "eurostat":
|
||||
heading = compact(" ".join(s.css(".ecl-page-header__info .ecl-label").xpath("string(.)").getall())).lower()
|
||||
if not heading:
|
||||
raise ValueError("Eurostat 公告缺少统计期/阶段标题")
|
||||
stage = ("flash" if "flash estimate" in heading else "final") if family == "ea_hicp" else ("preliminary" if heading.startswith("preliminary flash") else "flash" if heading.startswith("gdp and employment flash") or heading.startswith("flash estimate") else "final") if family == "ea_gdp" else None
|
||||
if family == "ea_hicp":
|
||||
p = re.search(r"(?:rate was|expected to be)\s*" + N + r"\s*% in ([A-Z][a-z]+ 20\d\d)", text)
|
||||
if p is None:
|
||||
raise ValueError("Eurostat 通胀公告缺少当期月份")
|
||||
period = period_of(p[1])
|
||||
row = eurostat_row(s, "All-items HICP")
|
||||
values["hicp_yoy"] = value(row[-2], row[-3])
|
||||
values["hicp_mom"] = value(row[-1])
|
||||
core = eurostat_row(s, "energy, food, alcohol & tobacco")
|
||||
values["core_hicp_yoy"] = value(core[-2], core[-3])
|
||||
elif family == "ea_gdp":
|
||||
p = re.search(r"(?:the )?((?:first|second|third|fourth) quarter of 20\d\d)", heading, re.I)
|
||||
if p is None:
|
||||
raise ValueError("Eurostat GDP 公告缺少季度")
|
||||
period = period_of(p[1], quarter=True)
|
||||
table = next((t for t in s.css("table") if "Growth rates of GDP in volume" in compact(t.xpath("string(.)").get())), None)
|
||||
if table is None:
|
||||
raise ValueError("Eurostat 缺少实际 GDP 增长表,不能使用就业增长表")
|
||||
row = eurostat_row(table, "Euro area")
|
||||
count = (len(row) - 1) // 2
|
||||
if len(row) != 1 + count * 2 or count < 2:
|
||||
raise ValueError("Eurostat GDP 表格列口径改变")
|
||||
values = {"gdp_qoq": value(row[count], row[count - 1]), "gdp_yoy": value(row[-1], row[-2])}
|
||||
else:
|
||||
p = re.search(r"In ([A-Z][a-z]+ 20\d\d), the euro area seasonally adjusted unemployment rate was\s*(" + N + r")%", text)
|
||||
if p is None:
|
||||
raise ValueError("Eurostat 公告未识别到欧元区季调失业率")
|
||||
period = period_of(p[1])
|
||||
table = next((t for t in s.css("table") if "Seasonally adjusted unemployment, totals" in compact(t.xpath("string(.)").get())), None)
|
||||
if table is None:
|
||||
raise ValueError("Eurostat 缺少全体失业率表,不能使用青年或分性别失业率")
|
||||
row = eurostat_row(table, "Euro area")
|
||||
count = (len(row) - 1) // 2
|
||||
values["ilo_unemployment"] = value(row[count], row[count - 1])
|
||||
elif source == "ons":
|
||||
stage = "initial" if "first quarterly estimate" in title.lower() else "final" if family == "uk_gdp" else None
|
||||
if family == "uk_cpi":
|
||||
period = period_of(title)
|
||||
# ONS 旧模板有断行粘连、脚注和额外空白列;数值四列始终是前/当期年率、前/当期月率。
|
||||
cpi_rows = {re.sub(r"\s+|\[\d+\]", "", r[0]): r for r in rows if r}
|
||||
all_items = cpi_rows.get("CPIAllitems")
|
||||
core = cpi_rows.get("CPIexcfood,energy,alcoholandtobacco(coreCPI)")
|
||||
if all_items is None:
|
||||
raise ValueError("ONS 表格缺少 CPI All items")
|
||||
if core is None:
|
||||
raise ValueError("ONS 表格缺少核心 CPI,不能用核心 CPIH 替代")
|
||||
values = {"eu_cpi_yoy": value(all_items[-3], all_items[-4]), "eu_core_cpi_yoy": value(core[-3], core[-4])}
|
||||
table = next((t for t in s.css("table") if "CPIH, OOH component and CPI index" in compact(t.xpath("string(.)").get())), None)
|
||||
if table is None:
|
||||
raise ValueError("ONS 公告缺少月率历史表格")
|
||||
data = [r for r in table_rows(table) if len(r) == 10 and re.fullmatch(r"[A-Za-z]{3}", r[1])]
|
||||
if len(data) < 2:
|
||||
raise ValueError("ONS CPI 月率历史行不足")
|
||||
values["eu_cpi_mom"] = value(data[-1][7], data[-2][7])
|
||||
elif family == "uk_gdp":
|
||||
period = period_of(title, quarter=True)
|
||||
q = int(period[-1])
|
||||
label = f"Q{q} {period[:4]}"
|
||||
table = next((t for t in s.css("table") if "headline national accounts indicators" in compact(t.xpath("string(.)").get()).lower()), None)
|
||||
if table is None:
|
||||
raise ValueError("ONS 公告缺少实际 GDP 季率表")
|
||||
data = table_rows(table)
|
||||
for r in data:
|
||||
if r:
|
||||
r[0] = re.sub(r"^(20\d\d) (Q[1-4])$", r"\2 \1", r[0])
|
||||
current = row_named(data, label)
|
||||
prev_q = f"Q{q - 1} {period[:4]}" if q > 1 else f"Q4 {int(period[:4]) - 1}"
|
||||
previous = row_named(data, prev_q)
|
||||
values["gdp_qoq"] = value(current[1], previous[1])
|
||||
annual = re.search(r"(?<!Nominal )GDP is estimated to be\s*(" + N + r")% (higher|lower) in Quarter " + str(q) + r" " + period[:4] + r",? compared with the same quarter a year ago", text)
|
||||
if annual is not None:
|
||||
amount = Decimal(numeric(annual[1]))
|
||||
values["gdp_yoy"] = value(str(-abs(amount)) if annual[2] == "lower" else str(amount))
|
||||
else:
|
||||
annual = re.search(r"(?:Real GDP|(?<!Nominal )GDP) is estimated to have (increased|decreased|fallen) by (" + N + r")%(?: in Quarter " + str(q) + r" " + period[:4] + r")?, compared with the same quarter a year ago", text)
|
||||
if annual is not None:
|
||||
amount = Decimal(numeric(annual[2]))
|
||||
values["gdp_yoy"] = value(str(-abs(amount)) if annual[1] in ("decreased", "fallen") else str(amount))
|
||||
else:
|
||||
values["gdp_yoy"] = {"actual": None, "previous": None, "unavailable_reason": "官方当期公报未列示季度实际 GDP 同比,不以人均 GDP、名义 GDP 或全年增长代填。"}
|
||||
else:
|
||||
m = re.search(r"The UK unemployment rate for people aged 16 years and over was estimated at (" + N + r")% in ([^.]+)", text)
|
||||
if m is None:
|
||||
raise ValueError("ONS 公告缺少三个月 ILO 失业率")
|
||||
period = period_of(m[2])
|
||||
values["uk_unemployment"] = value(m[1])
|
||||
elif source == "destatis":
|
||||
if family == "de_cpi":
|
||||
stage = "preliminary" if "expected" in title.lower() else "final"
|
||||
p = re.search(r"Consumer price index,\s*([A-Z][a-z]+ 20\d\d)", text)
|
||||
if p is None:
|
||||
raise ValueError("Destatis CPI 缺少统计期")
|
||||
period = period_of(p[1])
|
||||
for label, prefix in (("Consumer price index", "eu_cpi"), ("Harmonised index of consumer prices", "hicp")):
|
||||
pattern = label + r",[^:]+:\s*(" + N + r")% on the same month a year earlier[^%]*?\s(" + N + r")% on the previous month"
|
||||
m = re.search(pattern, text)
|
||||
if m is None:
|
||||
raise ValueError(f"Destatis 公告缺少 {label} 年率/月率")
|
||||
values[prefix + "_yoy"], values[prefix + "_mom"] = value(m[1]), value(m[2])
|
||||
elif family == "de_gdp":
|
||||
stage = "final" if "detailed" in title.lower() or "detailed results" in text[:2000].lower() else "initial"
|
||||
p = re.search(r"Gross domestic product \(GDP\),\s*([1-4]\s*(?:st|nd|rd|th) quarter of 20\d\d)", text)
|
||||
if p is None:
|
||||
raise ValueError("Destatis GDP 缺少统计季度")
|
||||
period = period_of(re.sub(r"([1-4])\s+(st|nd|rd|th)", r"\1\2", p[1]), quarter=True)
|
||||
values["gdp_qoq"] = match_value(r"(" + N + r")% on the previous quarter \(price, seasonally and calendar adjusted\)", text)
|
||||
values["gdp_yoy"] = match_value(r"(" + N + r")% on the same quarter a year earlier \(price and calendar adjusted\)", text)
|
||||
else:
|
||||
m = re.search(r"Adjusted unemployment rate at (" + N + r")% in ([A-Z][a-z]+ 20\d\d)", text)
|
||||
if m is None:
|
||||
raise ValueError("Destatis 公告缺少经季调 ILO 失业率")
|
||||
period = period_of(m[2])
|
||||
values["ilo_unemployment"] = value(m[1])
|
||||
elif source == "insee":
|
||||
subtitle = compact(s.css(".sous-titre").xpath("string(.)").get() or "")
|
||||
# INSEE 的统计期与阶段在 h1 的副标题,而不是页面下方的下一期时间。
|
||||
subtitle = subtitle or title
|
||||
stage = "preliminary" if "provisoir" in subtitle else "final" if family == "fr_cpi" else "initial" if "première estimation" in subtitle else "final" if family == "fr_gdp" else None
|
||||
period = period_of(subtitle, quarter=family in ("fr_gdp", "fr_jobs"))
|
||||
if family == "fr_cpi":
|
||||
if stage == "preliminary":
|
||||
ipc = next((t for t in s.css("table") if "Glissement annuel" in compact(t.xpath("string(.)").get()) and "Variation mensuelle" in compact(t.xpath("string(.)").get())), None)
|
||||
if ipc is None:
|
||||
raise ValueError("INSEE CPI 初值缺少明确的年率/月率表")
|
||||
data = [r for r in table_rows(ipc) if r and re.fullmatch(r"\d{4}-\d{2}", r[0])]
|
||||
values["eu_cpi_yoy"] = value(data[0][1], data[1][1])
|
||||
values["eu_cpi_mom"] = value(data[0][2], data[1][2])
|
||||
h = next((r for r in rows if r and r[0].startswith("Ensemble IPCH")), None)
|
||||
if h is None:
|
||||
raise ValueError("INSEE CPI 初值缺少 HICP 年率")
|
||||
values["hicp_yoy"] = value(h[-1], h[-2])
|
||||
m = re.search(r"harmonisé.{0,220}?Sur un mois, il (baisserait|augmenterait|diminuerait|reculerait|rebondirait|se replierait)(?: légèrement)? de (" + N + r")\s*%", text)
|
||||
if m is not None:
|
||||
amount = numeric(m[2])
|
||||
values["hicp_mom"] = value(str(-abs(Decimal(amount))) if m[1] in ("baisserait", "diminuerait", "reculerait", "se replierait") else amount)
|
||||
elif re.search(r"harmonisé.{0,220}?Sur un mois, il serait stable", text):
|
||||
values["hicp_mom"] = value("0")
|
||||
else:
|
||||
raise ValueError("INSEE 初值缺少 HICP 月率")
|
||||
else:
|
||||
ipc = row_named(rows, "Ensemble")
|
||||
h = row_named(rows, "Ensemble IPCH")
|
||||
values = {"eu_cpi_mom": value(ipc[-2]), "eu_cpi_yoy": value(ipc[-1]), "hicp_mom": value(h[-2]), "hicp_yoy": value(h[-1])}
|
||||
history = next((t for t in s.css("table") if "IPC ISJ IPCH" in compact(" ".join(t.xpath(".//text()").getall()))), None)
|
||||
if history is not None:
|
||||
prior = row_named(table_rows(history), (date.fromisoformat(period + "-01") - timedelta(days=1)).strftime("%Y-%m"))
|
||||
values["eu_cpi_yoy"]["previous"] = numeric(prior[1])
|
||||
values["hicp_yoy"]["previous"] = numeric(prior[3])
|
||||
elif family == "fr_gdp":
|
||||
table = next((t for t in s.css("table") if "Le produit intérieur brut et ses composantes" in compact(t.xpath("string(.)").get())), None)
|
||||
if table is None:
|
||||
raise ValueError("INSEE GDP 缺少季度表格")
|
||||
data = [r for r in table_rows(table) if r and re.fullmatch(r"\d{4}-T[1-4]", r[0])]
|
||||
if data[0][0] != period.replace("Q", "T"):
|
||||
raise ValueError("INSEE GDP 图表期次与标题不符")
|
||||
values["gdp_qoq"] = value(data[0][-1], data[1][-1])
|
||||
# 这份公告的年度列是全年增长/年度结转,不是季度同比;不得代填。
|
||||
values["gdp_yoy"] = {"actual": None, "previous": None, "unavailable_reason": "官方当期公告未列示季度实际 GDP 同比;年度增长及增长结转不能代替季度同比。"}
|
||||
else:
|
||||
table = next((t for t in s.css("table") if "Taux de chômage au sens du BIT" in compact(t.xpath("string(.)").get())), None)
|
||||
if table is None:
|
||||
raise ValueError("INSEE 公告缺少全体 15 岁以上 ILO 失业率")
|
||||
data = [r for r in table_rows(table) if r and re.fullmatch(r"\d{4}-T[1-4]", r[0])]
|
||||
if data[0][0] != period.replace("Q", "T"):
|
||||
raise ValueError("INSEE 失业率图表期次与标题不符")
|
||||
values["ilo_unemployment"] = value(data[0][1], data[1][1])
|
||||
else:
|
||||
raise ValueError(f"未支持的欧洲公告:{family}")
|
||||
if set(values) != set(FAMILIES[family].indicators):
|
||||
raise ValueError(f"{family} 公告指标不完整")
|
||||
return Announcement(publication(source, s, text, family, stage), period, stage, values)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""欧洲原公告索引。只跟随官网列出的链接,历史错误和缺口交给回补报告。"""
|
||||
from datetime import date, datetime
|
||||
import json
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
from parsel import Selector
|
||||
import httpx
|
||||
|
||||
from alphapulse.calendar.europe import family_for
|
||||
from alphapulse.calendar.parsers import compact
|
||||
from alphapulse.core.official_sources import EUROSTAT_FEED, feed_entries
|
||||
|
||||
DESTATIS_ARCHIVES = {
|
||||
"de_cpi": "https://www.destatis.de/SiteGlobals/Forms/Suche/Presse/EN/Pressesuche_Formular_2.html?cl2Taxonomies_Themen_0=preise&cl2Taxonomies_Themen_1=verbraucherpreisindex",
|
||||
"de_gdp": "https://www.destatis.de/SiteGlobals/Forms/Suche/Presse/EN/Pressesuche_Formular_2.html?cl2Taxonomies_Themen_0=volkswirtschaftliche_gesamtrechnungen_inlandsprodukt",
|
||||
"de_jobs": "https://www.destatis.de/SiteGlobals/Forms/Suche/Presse/EN/Pressesuche_Formular_2.html?cl2Taxonomies_Themen_0=arbeitsmarkt&cl2Taxonomies_Themen_1=erwerbstaetigkeit",
|
||||
}
|
||||
|
||||
|
||||
def ons_links(body: str) -> tuple[list[str], str | None]:
|
||||
s = Selector(text=body)
|
||||
links = []
|
||||
for node in s.css("a[data-gtm-release-title]"):
|
||||
title = compact(Selector(text=node.attrib["data-gtm-release-title"]).xpath("string(.)").get())
|
||||
if family_for("ons", title) and not node.attrib["href"].endswith("timeseries"):
|
||||
links.append(urljoin("https://www.ons.gov.uk", node.attrib["href"]))
|
||||
following = s.css('a[rel="next"]::attr(href)').get()
|
||||
return links, urljoin("https://www.ons.gov.uk", following) if following else None
|
||||
|
||||
|
||||
def destatis_links(body: str, start: date, end: date) -> tuple[list[str], str | None]:
|
||||
s = Selector(text=body)
|
||||
links, days = [], []
|
||||
for node in s.css(".c-result"):
|
||||
date_text = compact(node.css(".c-result__date").xpath("string(.)").get() or "")
|
||||
if " of " not in date_text:
|
||||
raise ValueError("Destatis 历史索引缺少公告日期")
|
||||
day = datetime.strptime(date_text.rsplit(" of ", 1)[1], "%d %B %Y").date()
|
||||
days.append(day)
|
||||
a = node.css("h3 a")
|
||||
title = compact(a.xpath("string(.)").get())
|
||||
if start <= day <= end:
|
||||
url = urljoin("https://www.destatis.de/", a.attrib["href"])
|
||||
if url.endswith(("_611.html", "_132.html")) or (url.endswith("_811.html") and "quarter" in title):
|
||||
links.append(url)
|
||||
if not days:
|
||||
raise ValueError("Destatis 历史索引没有公告数据行")
|
||||
following = s.css("a.forward::attr(href)").get() if max(days) >= start else None
|
||||
return links, urljoin("https://www.destatis.de/", following) if following else None
|
||||
|
||||
|
||||
async def collect(source: str, start: date, end: date, fetch, known: set[str], errors: list[dict]):
|
||||
"""fetch 由回补入口负责限速、只读窗口与官方域名校验。"""
|
||||
links = []
|
||||
if source == "ecb":
|
||||
for year in range(start.year, end.year + 1):
|
||||
url = f"https://www.ecb.europa.eu/press/govcdec/mopo/{year}/html/index_include.en.html"
|
||||
doc = await fetch(source, url)
|
||||
links.extend(urljoin(url, a) for a in Selector(text=doc.body.decode()).css('a[href]::attr(href)').getall() if "/ecb.mp" in a and a.endswith(".en.html"))
|
||||
elif source == "boe":
|
||||
url = "https://www.bankofengland.co.uk/sitemap/minutes"
|
||||
doc = await fetch(source, url)
|
||||
for a in Selector(text=doc.body.decode()).css('a[href]::attr(href)').getall():
|
||||
if any(f"/monetary-policy-summary-and-minutes/{y}/" in a for y in range(start.year, end.year + 1)) and not a.endswith(".pdf"):
|
||||
links.append(urljoin(url, a))
|
||||
elif source == "eurostat":
|
||||
for page in range(1, 31):
|
||||
url = EUROSTAT_FEED + f"&_estatsearchportlet_WAR_estatsearchportlet_INSTANCE_OaTpFrwlabNK_pageNumber={page}"
|
||||
doc = await fetch(source, url)
|
||||
entries = feed_entries(doc.body, source)
|
||||
if not entries:
|
||||
break
|
||||
links.extend(e.url for e in entries if start <= e.at.date() <= end and family_for(source, e.title))
|
||||
if min(e.at.date() for e in entries) < start:
|
||||
break
|
||||
else:
|
||||
raise ValueError("Eurostat 归档超过已核实的 30 页范围,覆盖未完成")
|
||||
elif source == "ons":
|
||||
dates = {"after-day": start.day, "after-month": start.month, "after-year": start.year,
|
||||
"before-day": end.day, "before-month": end.month, "before-year": end.year}
|
||||
for keyword in ("consumer price inflation", "UK labour market", "GDP quarterly"):
|
||||
url = "https://www.ons.gov.uk/releasecalendar?" + urlencode({**dates, "release-type": "type-published", "keywords": keyword, "limit": 100})
|
||||
seen = set()
|
||||
while url:
|
||||
if url in seen:
|
||||
raise ValueError("ONS 归档分页形成循环")
|
||||
seen.add(url)
|
||||
doc = await fetch(source, url)
|
||||
found, url = ons_links(doc.body.decode())
|
||||
links.extend(found)
|
||||
elif source == "destatis":
|
||||
for initial in DESTATIS_ARCHIVES.values():
|
||||
url, seen = initial, set()
|
||||
while url:
|
||||
if url in seen:
|
||||
raise ValueError("Destatis 归档分页形成循环")
|
||||
seen.add(url)
|
||||
doc = await fetch(source, url)
|
||||
found, url = destatis_links(doc.body.decode(), start, end)
|
||||
links.extend(found)
|
||||
elif source == "insee":
|
||||
# 官网 agenda-diffusion 返回的六个统计公报系列 ID;不是当前修订时间序列。
|
||||
for family_id in (1250, 137, 1251, 109, 104):
|
||||
offset = 0
|
||||
while True:
|
||||
doc = await fetch(source, "https://www.insee.fr/fr/solr/consultation", query={
|
||||
"q": "*:*", "start": offset, "rows": 100,
|
||||
"filters": [{"field": "familleId", "value": str(family_id)}],
|
||||
"sortFields": [{"field": "dateDiffusion", "order": "desc"}],
|
||||
})
|
||||
result = json.loads(doc.body)
|
||||
if result["status"] != 0 or not result["documents"]:
|
||||
raise ValueError("INSEE 历史公报查询失败或没有目标系列")
|
||||
documents = result["documents"]
|
||||
for item in documents:
|
||||
day = datetime.fromisoformat(item["dateDiffusion"]).date()
|
||||
if start <= day <= end:
|
||||
links.append(f"https://www.insee.fr/fr/statistiques/{item['id']}")
|
||||
offset += len(documents)
|
||||
if offset >= result["numFounds"] or min(datetime.fromisoformat(d["dateDiffusion"]).date() for d in documents) < start:
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"未支持的欧洲归档来源:{source}")
|
||||
if not links:
|
||||
raise ValueError(f"{source} 官方归档没有识别到目标公告,历史覆盖未核实")
|
||||
documents = []
|
||||
for url in dict.fromkeys(links):
|
||||
if url in known:
|
||||
continue
|
||||
try:
|
||||
doc = await fetch(source, url)
|
||||
if source == "ons" and "/releases/" in doc.url:
|
||||
sel = Selector(text=doc.body.decode())
|
||||
title = compact(sel.css("h1").xpath("string(.)").get() or "")
|
||||
target = family_for(source, title)
|
||||
if target is None:
|
||||
raise ValueError("ONS 历史发布页不是目标公报")
|
||||
patterns = {"uk_cpi": "/bulletins/consumerpriceinflation/", "uk_gdp": "/bulletins/", "uk_jobs": "/bulletins/uklabourmarket/"}
|
||||
candidates = list(dict.fromkeys(urljoin(doc.url, u) for u in sel.css('a[href]::attr(href)').getall() if patterns[target[0]] in u))
|
||||
if len(candidates) != 1:
|
||||
raise ValueError("ONS 历史发布页未唯一指向目标公报")
|
||||
if candidates[0] in known:
|
||||
known.add(url)
|
||||
continue
|
||||
doc = await fetch(source, candidates[0])
|
||||
documents.append(doc)
|
||||
known.update((url, doc.url))
|
||||
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
||||
errors.append({"source": source, "url": url, "error": str(exc)})
|
||||
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in (429, 503):
|
||||
errors.append({"source": source, "error": "官网限流或暂不可用,已停止该来源回补;本次历史覆盖不完整,请稍后重跑。", "retry_after": exc.response.headers.get("Retry-After")})
|
||||
break
|
||||
return documents
|
||||
@@ -0,0 +1,233 @@
|
||||
"""官方来源的窄口径解析;页面改变或数据缺失时报错,不返回伪造的零值。"""
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from urllib.parse import urljoin
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from parsel import Selector
|
||||
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
MONTHS = {name: i for i, name in enumerate(
|
||||
("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"), 1)}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scheduled:
|
||||
external_id: str
|
||||
family: str
|
||||
title: str
|
||||
at: datetime
|
||||
status: str = "scheduled"
|
||||
period: str | None = None
|
||||
stage: str | None = None
|
||||
release_url: str | None = None
|
||||
|
||||
|
||||
def compact(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def bea_family(title: str) -> str | None:
|
||||
if title.startswith("Personal Income and Outlays"):
|
||||
return "pce"
|
||||
return "gdp" if re.match(r"(?:Gross Domestic Product|GDP)\s*[(,]", title) else None
|
||||
|
||||
|
||||
def bea_release_link(body: str, day: date, family: str) -> str | None:
|
||||
sel = Selector(text=body, type="html")
|
||||
rows = sel.css("tr.scheduled-releases-type-press")
|
||||
if not rows:
|
||||
raise ValueError("BEA 公告日程缺少数据行")
|
||||
matches = []
|
||||
for row in rows:
|
||||
title = compact(row.css(".release-title").xpath("string(.)").get())
|
||||
date_text = compact(row.css(".release-date").xpath("string(.)").get())
|
||||
if date_text != day.strftime("%B ") + str(day.day) or bea_family(title) != family:
|
||||
continue
|
||||
link = row.css('a[href*="/news/"]::attr(href)').get()
|
||||
if link and f"/news/{day.year}/" in link:
|
||||
matches.append(urljoin("https://www.bea.gov", link))
|
||||
if len(matches) > 1:
|
||||
raise ValueError("BEA 同日同类公告不唯一,不能确定结果")
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def parse_ics(body: str, source: str) -> list[Scheduled]:
|
||||
body = re.sub(r"\r?\n[ \t]", "", body)
|
||||
if "BEGIN:VCALENDAR" not in body or "END:VCALENDAR" not in body:
|
||||
raise ValueError("官方日历不是完整 iCalendar")
|
||||
results = []
|
||||
for block in re.findall(r"BEGIN:VEVENT(.*?)END:VEVENT", body, re.S):
|
||||
fields = dict(line.split(":", 1) for line in block.splitlines() if ":" in line)
|
||||
title = fields["SUMMARY"].replace(r"\,", ",").replace(r"\n", " ")
|
||||
family = {"Consumer Price Index": "cpi", "Producer Price Index": "ppi", "Employment Situation": "jobs"}.get(title)
|
||||
if source == "bea":
|
||||
family = bea_family(title)
|
||||
if family is None:
|
||||
continue
|
||||
key, value = next((k, v) for k, v in fields.items() if k.split(";")[0] == "DTSTART")
|
||||
if value.endswith("Z"):
|
||||
at = datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
|
||||
elif "TZID=US-Eastern" in key or "TZID=America/New_York" in key:
|
||||
at = datetime.strptime(value, "%Y%m%dT%H%M%S").replace(tzinfo=EASTERN).astimezone(UTC)
|
||||
else:
|
||||
raise ValueError(f"日历缺少受支持的精确时区:{key}")
|
||||
status = fields.get("STATUS", "CONFIRMED")
|
||||
if status not in ("CONFIRMED", "TENTATIVE", "CANCELLED"):
|
||||
raise ValueError(f"不支持的日历状态:{status}")
|
||||
period, stage = release_metadata(family, title)
|
||||
results.append(Scheduled(fields["UID"], family, title, at, "withdrawn" if status == "CANCELLED" else "scheduled", period, stage))
|
||||
if not results:
|
||||
raise ValueError("官方日历未找到美国目标指标")
|
||||
return results
|
||||
|
||||
|
||||
def parse_fed_calendar(body: str) -> list[Scheduled]:
|
||||
sel = Selector(text=body, type="html")
|
||||
heading = next((compact(n.xpath("string(.)").get()) for n in sel.css("h2,h3,h4")
|
||||
if re.fullmatch(r"[A-Za-z]+ 20\d\d", compact(n.xpath("string(.)").get()))), None)
|
||||
if heading is None:
|
||||
raise ValueError("美联储月历缺少年月")
|
||||
month, year = heading.split()
|
||||
results = []
|
||||
for node in sel.xpath('//p[normalize-space(.)="FOMC Meeting"]'):
|
||||
row = node.xpath("../..")
|
||||
clock = compact(row.css(".col-xs-2").xpath("string(.)").get()).replace("a.m.", "AM").replace("p.m.", "PM")
|
||||
day = int(compact(row.css(".col-xs-3").xpath("string(.)").get()))
|
||||
time = datetime.strptime(clock, "%I:%M %p")
|
||||
at = datetime(int(year), MONTHS[month], day, time.hour, time.minute, tzinfo=EASTERN).astimezone(UTC)
|
||||
results.append(Scheduled(f"fomc-{year}-{MONTHS[month]:02d}-{day:02d}", "fomc", "FOMC 利率决议", at))
|
||||
# 官方月份无会议是正常状态;无法识别页面结构则不能解释成无会议。
|
||||
if not sel.css(".cal-nojs__rowTitle"):
|
||||
raise ValueError("美联储月历结构无法识别")
|
||||
return results
|
||||
|
||||
|
||||
def release_date(body: str) -> datetime:
|
||||
text = compact(Selector(text=body, type="html").xpath("string(//body)").get())
|
||||
match = re.search(r"(?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}, 20\d{2}", text)
|
||||
if match is None:
|
||||
raise ValueError("公告缺少正式发布日期")
|
||||
return datetime.strptime(match[0], "%B %d, %Y").replace(tzinfo=EASTERN)
|
||||
|
||||
|
||||
def number(value: str) -> str:
|
||||
cleaned = value.strip().replace(",", "").replace("−", "-")
|
||||
if not re.fullmatch(r"[+-]?\d+(?:\.\d+)?", cleaned):
|
||||
raise ValueError(f"公布值不是有效数字:{value!r}")
|
||||
return str(Decimal(cleaned))
|
||||
|
||||
|
||||
def cells(row: Selector) -> list[str]:
|
||||
return [compact(n.xpath("string(.)").get()) for n in row.xpath("./th|./td")]
|
||||
|
||||
|
||||
def release_metadata(family: str, title: str) -> tuple[str | None, str | None]:
|
||||
"""只解析来源明示的统计期;不以发布时间推算上月或上季。"""
|
||||
if family == "gdp":
|
||||
quarter = re.search(r"(1st|2nd|3rd|4th|first|second|third|fourth)\s+quarter(?:\s+and\s+year|\s+of)?\s+(20\d\d)", title, re.I)
|
||||
stages = re.search(r"\b(advance|second|third|initial|updated)\s+estimate\b", title, re.I)
|
||||
numbers = {"1st": 1, "2nd": 2, "3rd": 3, "4th": 4, "first": 1, "second": 2, "third": 3, "fourth": 4}
|
||||
return (f"{quarter[2]}-Q{numbers[quarter[1].lower()]}" if quarter else None,
|
||||
stages[1].lower() if stages else None)
|
||||
month = re.search(r"\b(" + "|".join(MONTHS) + r")\s+(20\d\d)\b", title, re.I)
|
||||
return (f"{month[2]}-{MONTHS[month[1].title()]:02d}" if month else None, None)
|
||||
|
||||
|
||||
def bls_periods(body: str) -> dict[datetime, str]:
|
||||
sel = Selector(text=body, type="html")
|
||||
periods = {}
|
||||
for node in sel.css("table tr"):
|
||||
row = cells(node)
|
||||
if len(row) != 3 or not re.fullmatch(r"[A-Za-z]+ 20\d\d", row[0]):
|
||||
continue
|
||||
period, _ = release_metadata("cpi", row[0])
|
||||
day = row[1].replace(".", "")
|
||||
at = datetime.strptime(day + " " + row[2], "%b %d, %Y %I:%M %p").replace(tzinfo=EASTERN).astimezone(UTC)
|
||||
periods[at] = period
|
||||
if not periods:
|
||||
raise ValueError("BLS 分项日程缺少统计月份")
|
||||
return periods
|
||||
|
||||
|
||||
def parse_values(family: str, body: str) -> dict:
|
||||
sel = Selector(text=body, type="html")
|
||||
content = sel.css("#bodytext,.field--name-body,#article")
|
||||
if not content:
|
||||
raise ValueError("官方公告缺少正文")
|
||||
text = compact(content.xpath("string(.)").get()).replace("‑", "-")
|
||||
values = {}
|
||||
|
||||
def put(key: str, actual: str, previous: str | None = None) -> None:
|
||||
values[key] = {"actual": number(actual), "previous": number(previous) if previous is not None else None}
|
||||
|
||||
if family == "cpi":
|
||||
rows = {r[0]: r[1:] for n in sel.css("#cpi_pressa tr") if len(r := cells(n)) == 9}
|
||||
for label, prefix in [("All items", "cpi"), ("All items less food and energy", "core_cpi")]:
|
||||
row = rows[label]
|
||||
previous = None if row[-3] == "-" else row[-3]
|
||||
if row[-2] == "-":
|
||||
values[prefix + "_mom"] = {"actual": None, "previous": number(previous) if previous is not None else None,
|
||||
"unavailable_reason": "官方表格未发布该期单月读数;不能用两个月累计变化替代月率。"}
|
||||
else:
|
||||
put(prefix + "_mom", row[-2], previous)
|
||||
put(prefix + "_yoy", row[-1])
|
||||
elif family == "ppi":
|
||||
rows = [r for n in sel.css("#ppi_nrtablea tr") if len(r := cells(n)) == 13 and re.match(r"[A-Z][a-z]", r[0])]
|
||||
if len(rows) < 2:
|
||||
raise ValueError("PPI 缺少本期与前期数据")
|
||||
put("ppi_mom", rows[-1][1], rows[-2][1])
|
||||
put("ppi_yoy", rows[-1][-2], rows[-2][-2])
|
||||
elif family == "jobs":
|
||||
employment = re.search(r"nonfarm payroll employment (?:(?:changed little(?: in [A-Za-z]+)?\s*)?\(([+-]?[\d,]+)\)|(?:rose|increased|declined|decreased|fell|edged up|edged down)(?: by)? ([\d,]+))", text)
|
||||
if employment is None:
|
||||
raise ValueError("就业报告缺少非农新增值")
|
||||
actual = Decimal(number(employment[1] or employment[2]))
|
||||
if re.search(r"declined|decreased|fell|edged down", employment[0]):
|
||||
actual = -actual
|
||||
revised = re.findall(r"change for \w+ was revised (?:up|down) by [\d,]+, from [+-]?[\d,]+ to ([+-]?[\d,]+)", text)
|
||||
previous = str(Decimal(number(revised[-1])) / 10000) if revised else None
|
||||
put("payrolls", str(actual / 10000), previous)
|
||||
rate = re.search(r"unemployment rate(?:, at| \(| (?:was unchanged|was|remained|held|changed little)(?: at)?| (?:rose|increased|fell|declined|decreased|edged down|edged up) to)\s*(\d+(?:\.\d+)?) percent", text)
|
||||
if rate is None:
|
||||
raise ValueError("就业报告缺少失业率")
|
||||
put("unemployment", rate[1])
|
||||
elif family == "pce":
|
||||
rows = {r[0].replace("index,", "index"): r[1:] for n in sel.css("table tr") if len(r := cells(n)) in (2, 3)}
|
||||
for label, key in [("PCE price index", "pce_mom"), ("PCE price index excluding food and energy", "core_pce_mom")]:
|
||||
if label in rows:
|
||||
put(key, rows[label][-1], rows[label][-2] if len(rows[label]) == 2 else None)
|
||||
else:
|
||||
clause = (r"From the preceding month, the PCE price index(?: for \w+)? " if key == "pce_mom"
|
||||
else r"Excluding food and energy, the PCE price index (?:also )?")
|
||||
change = re.search(clause + r"(increased|decreased) (\d+(?:\.\d+)?) percent", text)
|
||||
if change is None:
|
||||
raise ValueError(f"PCE 公告缺少精确月率:{key}")
|
||||
put(key, ("-" if change[1] == "decreased" else "") + change[2])
|
||||
annual = next((compact(p.xpath("string(.)").get()) for p in content.css("p")
|
||||
if "From the same month one year ago" in p.xpath("string(.)").get()), "")
|
||||
changes = re.findall(r"(increased|decreased) (\d+(?:\.\d+)?) percent", annual)
|
||||
if len(changes) != 2 or "Excluding food and energy" not in annual:
|
||||
raise ValueError("PCE 公告缺少整体与核心年率")
|
||||
for key, (movement, amount) in zip(("pce_yoy", "core_pce_yoy"), changes, strict=True):
|
||||
put(key, ("-" if movement == "decreased" else "") + amount)
|
||||
elif family == "gdp":
|
||||
match = re.search(r"Real gross domestic product \(GDP\) (increased|decreased) at an annual rate of (\d+(?:\.\d+)?) percent", text, re.I)
|
||||
if match is None:
|
||||
raise ValueError("GDP 公告缺少实际年化季率")
|
||||
put("gdp", ("-" if match[1].lower() == "decreased" else "") + match[2])
|
||||
elif family == "fomc":
|
||||
match = re.search(r"target range for the federal funds rate.{0,100}? to (\d+(?:[.-]\d+(?:/\d+)?)?(?:/\d+)?) percent", text)
|
||||
if match is None:
|
||||
raise ValueError("FOMC 声明缺少利率目标上限")
|
||||
raw = match[1]
|
||||
if "/" in raw:
|
||||
whole, fraction = raw.split("-", 1) if "-" in raw else ("0", raw)
|
||||
numerator, denominator = fraction.split("/")
|
||||
raw = str(Decimal(whole) + Decimal(numerator) / Decimal(denominator))
|
||||
put("fed_rate", raw)
|
||||
else:
|
||||
raise ValueError(f"不支持的指标:{family}")
|
||||
return values
|
||||
@@ -0,0 +1,234 @@
|
||||
import gzip
|
||||
import hashlib
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from alphapulse.calendar.catalog import COUNTRIES, FAMILIES, INDICATORS, STAGES
|
||||
from alphapulse.calendar.parsers import Scheduled
|
||||
from alphapulse.models import EconomicRelease, EconomicRevision, EconomicSeriesValue, RawBody, RawResponse
|
||||
|
||||
|
||||
def next_poll(at: datetime, now: datetime, received: bool, failed: bool = False) -> datetime:
|
||||
age = (now - at).total_seconds()
|
||||
if age < 0:
|
||||
return at
|
||||
if received:
|
||||
seconds = 60 if age < 900 else 3600 if age < 86400 else 86400
|
||||
else:
|
||||
seconds = 5 if age < 120 else 15 if age < 900 else 60 if age < 7200 else 900
|
||||
if failed:
|
||||
seconds = max(seconds, 60)
|
||||
return now + timedelta(seconds=seconds)
|
||||
|
||||
|
||||
async def save_raw(session: AsyncSession, source: str, url: str, status: int, headers: dict, body: bytes, now: datetime, *, method: str = "GET") -> int:
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
await session.execute(insert(RawBody).values(sha256=digest, body=gzip.compress(body)).on_conflict_do_nothing())
|
||||
row = RawResponse(source_code=source, url=url, method=method, status=status, headers=headers,
|
||||
body_sha256=digest, fetched_at=now)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
return row.id
|
||||
|
||||
|
||||
def revision_payload(row: EconomicRelease) -> dict:
|
||||
return {"title": row.title, "period": row.period, "stage": row.stage,
|
||||
"scheduled_at": row.scheduled_at.isoformat(), "schedule_status": row.schedule_status,
|
||||
"release_url": row.release_url, "values": row.values,
|
||||
"first_received_at": row.first_received_at.isoformat() if row.first_received_at else None}
|
||||
|
||||
|
||||
def record_revision(session: AsyncSession, row: EconomicRelease, raw_id: int) -> None:
|
||||
session.add(EconomicRevision(release_id=row.id, revision_no=row.revision_no,
|
||||
payload=revision_payload(row), raw_response_id=raw_id))
|
||||
|
||||
|
||||
async def save_schedule(session: AsyncSession, source: str, url: str, events: list[Scheduled], raw_id: int,
|
||||
now: datetime, *, withdraw_missing: bool = True) -> None:
|
||||
existing = list(await session.scalars(select(EconomicRelease).where(EconomicRelease.schedule_url == url).with_for_update()))
|
||||
by_id = {r.external_id: r for r in existing}
|
||||
seen = set()
|
||||
for event in events:
|
||||
if event.at < datetime(2024, 12, 31, 16, tzinfo=UTC):
|
||||
continue
|
||||
seen.add(event.external_id)
|
||||
row = by_id.get(event.external_id)
|
||||
if row is None:
|
||||
# 归档回补先于 ICS 入库时,以正式时间和期次接管已有事件,保留主键和首次取值。
|
||||
matches = list(await session.scalars(select(EconomicRelease).where(
|
||||
EconomicRelease.source == source, EconomicRelease.family == event.family,
|
||||
EconomicRelease.scheduled_at == event.at,
|
||||
).with_for_update()))
|
||||
matches = [r for r in matches if (event.period is None or r.period in (None, event.period))
|
||||
and (event.stage is None or r.stage in (None, event.stage))]
|
||||
if len(matches) > 1:
|
||||
raise ValueError("日程匹配到多个同系列同时间事件")
|
||||
if matches:
|
||||
row = matches[0]
|
||||
row.external_id, row.schedule_url = event.external_id, url
|
||||
if row is None:
|
||||
row = EconomicRelease(source=source, external_id=event.external_id, family=event.family, title=event.title,
|
||||
scheduled_at=event.at, schedule_status=event.status, schedule_url=url,
|
||||
period=event.period, stage=event.stage,
|
||||
release_url=event.release_url,
|
||||
schedule_checked_at=now, next_check_at=max(now, event.at), values={}, revision_no=1)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
record_revision(session, row, raw_id)
|
||||
else:
|
||||
changed = (row.scheduled_at != event.at or row.title != event.title or row.schedule_status != event.status
|
||||
or (event.period is not None and row.period != event.period)
|
||||
or (event.stage is not None and row.stage != event.stage)
|
||||
or (event.release_url is not None and row.release_url is None))
|
||||
row.schedule_checked_at = now
|
||||
if changed:
|
||||
row.scheduled_at, row.title, row.schedule_status = event.at, event.title, event.status
|
||||
if event.period is not None:
|
||||
row.period = event.period
|
||||
if event.stage is not None:
|
||||
row.stage = event.stage
|
||||
if row.release_url is None and event.release_url is not None:
|
||||
row.release_url = event.release_url
|
||||
row.next_check_at = max(now, event.at)
|
||||
row.revision_no += 1
|
||||
record_revision(session, row, raw_id)
|
||||
# 仅撤回尚未公布的未来事件;过去记录不因官网滚动窗口而被删除。
|
||||
for row in existing:
|
||||
if withdraw_missing and row.external_id not in seen and row.scheduled_at > now and row.first_received_at is None and row.schedule_status != "withdrawn":
|
||||
row.schedule_status = "withdrawn"
|
||||
row.revision_no += 1
|
||||
record_revision(session, row, raw_id)
|
||||
|
||||
|
||||
def save_result(session: AsyncSession, row: EconomicRelease, values: dict, url: str, raw_id: int, now: datetime,
|
||||
*, period: str | None = None, stage: str | None = None) -> None:
|
||||
if ((period is not None and row.period is not None and period != row.period)
|
||||
or (stage is not None and row.stage is not None and stage != row.stage)):
|
||||
raise ValueError("公告统计期或发布阶段与日历不符")
|
||||
if row.values != values or row.release_url != url or (period is not None and row.period != period) or (stage is not None and row.stage != stage):
|
||||
row.values = values
|
||||
row.release_url = url
|
||||
if period is not None:
|
||||
row.period = period
|
||||
if stage is not None:
|
||||
row.stage = stage
|
||||
if row.first_received_at is None:
|
||||
row.first_received_at = now
|
||||
row.revision_no += 1
|
||||
record_revision(session, row, raw_id)
|
||||
|
||||
|
||||
async def save_series(session: AsyncSession, source: str, indicator: str, points: list[tuple[str, str]],
|
||||
stage: str, raw_id: int, now: datetime) -> None:
|
||||
for period, value in points:
|
||||
stmt = insert(EconomicSeriesValue).values(source=source, indicator=indicator, period=period, stage=stage,
|
||||
value=value, raw_response_id=raw_id, captured_at=now)
|
||||
await session.execute(stmt.on_conflict_do_update(
|
||||
index_elements=["source", "indicator", "period", "stage"],
|
||||
set_={"value": value, "raw_response_id": raw_id, "captured_at": now},
|
||||
where=EconomicSeriesValue.captured_at < now,
|
||||
))
|
||||
|
||||
|
||||
async def apply_bls_periods(session: AsyncSession, family: str, periods: dict[datetime, str], raw_id: int) -> None:
|
||||
rows = await session.scalars(select(EconomicRelease).where(EconomicRelease.source == "bls",
|
||||
EconomicRelease.family == family,
|
||||
EconomicRelease.scheduled_at.in_(periods)).with_for_update())
|
||||
for row in rows:
|
||||
period = periods[row.scheduled_at]
|
||||
if row.period != period:
|
||||
row.period = period
|
||||
row.revision_no += 1
|
||||
record_revision(session, row, raw_id)
|
||||
|
||||
|
||||
def impact(key: str, actual: str | None, previous: str | None, forecast: str | None = None) -> tuple[str, str]:
|
||||
if actual is None:
|
||||
return "pending", "等待官方公布"
|
||||
reference = forecast if forecast is not None else previous
|
||||
if reference is None:
|
||||
return "uncertain", "已取得公布值,缺少可比前值和市场一致预期,暂不能判断影响。"
|
||||
basis = "市场预期" if forecast is not None else "前值"
|
||||
change = Decimal(actual) - Decimal(reference)
|
||||
movement = "上升" if change > 0 else "下降" if change < 0 else "持平"
|
||||
caveat = "未取得市场一致预期,这不是超预期判断。" if forecast is None else ""
|
||||
if not change:
|
||||
return "neutral", f"读数与{basis}一致,单项变化归为影响较小。{caveat}实际波动仍取决于其他数据和市场定价。"
|
||||
channel = INDICATORS[key].rate_channel
|
||||
if channel == "ambiguous":
|
||||
return "uncertain", f"读数较{basis}{movement}。政策、增长和汇率通道可能产生相反影响,暂不归为 BTC/ETH 的利多或利空。{caveat}"
|
||||
bullish = change < 0
|
||||
if key == "fed_rate":
|
||||
text = f"利率目标上限较{basis}{movement},按利率与流动性通道参考判断。"
|
||||
else:
|
||||
text = f"通胀读数较{basis}{movement},按通胀影响利率预期的通道参考判断。"
|
||||
return "bullish" if bullish else "bearish", text + caveat + "标签面向 BTC/ETH,不代表币价必然涨跌。"
|
||||
|
||||
|
||||
def release_rows(row: EconomicRelease, now: datetime) -> list[dict]:
|
||||
if row.schedule_status == "withdrawn":
|
||||
state = "withdrawn"
|
||||
elif row.first_received_at is not None:
|
||||
state = "released"
|
||||
elif now - row.scheduled_at > timedelta(days=45):
|
||||
state = "historical"
|
||||
elif row.error:
|
||||
state = "error"
|
||||
elif now < row.scheduled_at:
|
||||
state = "scheduled"
|
||||
else:
|
||||
state = "waiting" if now - row.scheduled_at < timedelta(minutes=2) else "delayed"
|
||||
result = []
|
||||
family = FAMILIES[row.family]
|
||||
country_name = COUNTRIES[family.country_code]
|
||||
period_name = ""
|
||||
if row.period is not None:
|
||||
period_name = f"第{row.period[-1]}季度" if "-Q" in row.period else f"{int(row.period[5:7])}月"
|
||||
for key in family.indicators:
|
||||
indicator = INDICATORS[key]
|
||||
value = row.values.get(key, {})
|
||||
actual, previous = value.get("actual"), value.get("previous")
|
||||
forecast = value.get("forecast")
|
||||
direction, explanation = impact(key, actual, previous, forecast)
|
||||
unavailable = actual is None and value.get("unavailable_reason")
|
||||
if unavailable:
|
||||
direction, explanation = "uncertain", value["unavailable_reason"]
|
||||
result.append({"release_id": row.id, "indicator": key, "name": indicator.name, "unit": indicator.unit,
|
||||
"country_code": family.country_code, "country_name": country_name,
|
||||
"event_name": f"{country_name}{period_name}{indicator.name}{STAGES[row.stage] if row.stage else ''}",
|
||||
"period": row.period, "stage": row.stage, "stage_name": STAGES[row.stage] if row.stage else None,
|
||||
"title": row.title, "source": row.source, "scheduled_at": row.scheduled_at,
|
||||
"importance": indicator.importance, "status": "unavailable" if unavailable and state == "released" else state,
|
||||
"actual": actual, "previous": previous, "forecast": forecast,
|
||||
"previous_basis": value.get("previous_basis", "官方本次报告列示前值(可能已修订)") if previous is not None else None,
|
||||
"impact_basis": "forecast" if forecast is not None else "previous" if previous is not None else None,
|
||||
"impact": direction, "explanation": explanation, "release_url": row.release_url,
|
||||
"schedule_url": row.schedule_url, "first_received_at": row.first_received_at,
|
||||
"last_checked_at": row.last_checked_at, "error": row.error, "revision_no": row.revision_no})
|
||||
return result
|
||||
|
||||
|
||||
async def indicator_history(session: AsyncSession, row: EconomicRelease, key: str, limit: int) -> list[dict]:
|
||||
# 指标代码和所属发布系列共同限定历史,防止核心/整体、年率/月率以及初值/终值混在一起。
|
||||
first_actual = (select(EconomicRevision.payload["values"][key]["actual"].as_string())
|
||||
.where(EconomicRevision.release_id == EconomicRelease.id,
|
||||
EconomicRevision.payload["values"][key]["actual"].as_string().is_not(None))
|
||||
.order_by(EconomicRevision.revision_no).limit(1).correlate(EconomicRelease).scalar_subquery())
|
||||
rows = (await session.execute(select(EconomicRelease, first_actual).where(
|
||||
EconomicRelease.source == row.source, EconomicRelease.family == row.family,
|
||||
EconomicRelease.stage.is_not_distinct_from(row.stage),
|
||||
EconomicRelease.scheduled_at <= row.scheduled_at,
|
||||
EconomicRelease.schedule_status != "withdrawn", EconomicRelease.first_received_at.is_not(None),
|
||||
EconomicRelease.values[key]["actual"].as_string().is_not(None),
|
||||
).order_by(EconomicRelease.scheduled_at.desc(), EconomicRelease.id.desc()).limit(limit))).all()
|
||||
return [{"release_id": item.id, "scheduled_at": item.scheduled_at, "title": item.title,
|
||||
"period": item.period, "stage": item.stage,
|
||||
"actual": item.values[key]["actual"], "previous": item.values[key].get("previous"),
|
||||
"forecast": item.values[key].get("forecast"), "first_recorded_actual": initial,
|
||||
"first_received_at": item.first_received_at, "release_url": item.release_url,
|
||||
"revised": initial is not None and Decimal(initial) != Decimal(item.values[key]["actual"])}
|
||||
for item, initial in rows]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""密歇根公开日程、当前公告和月度终值;三者的时间与版本口径分开。"""
|
||||
import csv
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from io import BytesIO, StringIO
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from parsel import Selector
|
||||
from pypdf import PdfReader
|
||||
from pypdf.errors import PdfReadError
|
||||
|
||||
from alphapulse.calendar.parsers import EASTERN, MONTHS, Scheduled, cells, compact, number
|
||||
|
||||
|
||||
def pdf_text(body: bytes, pages: int | None = None) -> str:
|
||||
try:
|
||||
reader = PdfReader(BytesIO(body))
|
||||
text = "\n".join(page.extract_text() for page in reader.pages[:pages])
|
||||
except PdfReadError as exc:
|
||||
raise ValueError("官方 PDF 无法读取") from exc
|
||||
if not text.strip():
|
||||
raise ValueError("官方 PDF 缺少可提取文字")
|
||||
return text
|
||||
|
||||
|
||||
def document_links(body: str, base: str, labels: set[str]) -> dict[str, str]:
|
||||
links = {compact(a.xpath("string(.)").get()): urljoin(base, a.attrib["href"])
|
||||
for a in Selector(text=body, type="html").css("a[href]")}
|
||||
return {label: links[label] for label in labels if label in links}
|
||||
|
||||
|
||||
def parse_schedule(text: str) -> list[Scheduled]:
|
||||
events = []
|
||||
# 历史 PDF 明确列出调查月份、初值日期和终值日期,包括跨月公布。
|
||||
for match in re.finditer(r"\b([A-Z][a-z]{2})\s+(20\d\d)\s+(\d{1,2}/\d{1,2}/\d{4})\s+(\d{1,2}/\d{1,2}/\d{4})", text):
|
||||
month = datetime.strptime(match[1], "%b").month
|
||||
period = f"{match[2]}-{month:02d}"
|
||||
for stage, value in zip(("preliminary", "final"), (match[3], match[4]), strict=True):
|
||||
at = datetime.strptime(value, "%m/%d/%Y").replace(hour=10, tzinfo=EASTERN).astimezone(UTC)
|
||||
events.append(Scheduled(f"umich-{period}-{stage}", "umich", "University of Michigan Consumer Sentiment", at,
|
||||
period=period, stage=stage))
|
||||
if events:
|
||||
if len({e.external_id for e in events}) != len(events):
|
||||
raise ValueError("密歇根日程存在重复期次")
|
||||
return events
|
||||
year = re.search(r"RELEASE DATES FOR (20\d\d)", text)
|
||||
if year:
|
||||
for match in re.finditer(r"([A-Z][a-z]+)\s+(\d{1,2})\s+([A-Z][a-z]+)\s+(Prelim|Final)\b", text):
|
||||
period = f"{year[1]}-{MONTHS[match[3]]:02d}"
|
||||
stage = "preliminary" if match[4] == "Prelim" else "final"
|
||||
at = datetime(int(year[1]), MONTHS[match[1]], int(match[2]), 10, tzinfo=EASTERN).astimezone(UTC)
|
||||
events.append(Scheduled(f"umich-{period}-{stage}", "umich", "University of Michigan Consumer Sentiment", at,
|
||||
period=period, stage=stage))
|
||||
if not events:
|
||||
raise ValueError("密歇根日程 PDF 缺少可识别的公布日期")
|
||||
if len({e.external_id for e in events}) != len(events):
|
||||
raise ValueError("密歇根日程存在重复期次")
|
||||
return events
|
||||
|
||||
|
||||
def current_result(body: str) -> tuple[str, str, dict]:
|
||||
sel = Selector(text=body, type="html")
|
||||
heading = compact(sel.css("h1").xpath("string(.)").get())
|
||||
match = re.fullmatch(r"(Preliminary|Final) Results for ([A-Z][a-z]+) (20\d\d)", heading)
|
||||
if not match:
|
||||
raise ValueError("密歇根当前公告缺少统计月份或发布阶段")
|
||||
period = f"{match[3]}-{MONTHS[match[2]]:02d}"
|
||||
stage = "preliminary" if match[1] == "Preliminary" else "final"
|
||||
rows = [cells(r) for r in sel.css("#front_table tr")]
|
||||
if len(rows) < 3 or any(len(row) < 3 for row in rows) or rows[0][1] != match[2][:3] or rows[1][1] != match[3]:
|
||||
raise ValueError("密歇根表头与公告期次不符")
|
||||
row = next((r for r in rows if r[0] == "Index of Consumer Sentiment"), None)
|
||||
if row is None:
|
||||
raise ValueError("密歇根公告缺少消费者信心指数")
|
||||
# 官网列的是上月终值,不能标成本期初值;两者在详情中明确区别。
|
||||
previous_period = datetime.strptime(rows[0][2] + " " + rows[1][2], "%b %Y").strftime("%Y-%m")
|
||||
return period, stage, {"umich_sentiment": {"actual": number(row[1]), "previous": number(row[2]),
|
||||
"previous_basis": f"{previous_period} 官方上月终值"}}
|
||||
|
||||
|
||||
def monthly_series(body: str) -> list[tuple[str, str]]:
|
||||
reader = csv.DictReader(StringIO(body.lstrip("\ufeff")))
|
||||
if reader.fieldnames != ["Month", "YYYY", "ICS_ALL"]:
|
||||
raise ValueError("密歇根月度 CSV 列名不符")
|
||||
result = []
|
||||
for row in reader:
|
||||
period = f"{int(row['YYYY']):04d}-{MONTHS[row['Month']]:02d}"
|
||||
result.append((period, number(row["ICS_ALL"])))
|
||||
if not result or len({p for p, _ in result}) != len(result):
|
||||
raise ValueError("密歇根月度 CSV 为空或统计期重复")
|
||||
return result
|
||||
@@ -0,0 +1,334 @@
|
||||
"""独立日历采集进程:日程每小时刷新,公布窗口按 5/15/60 秒检查官方结果。"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from email.utils import parsedate_to_datetime
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from parsel import Selector
|
||||
from sqlalchemy import select
|
||||
|
||||
from alphapulse.calendar.catalog import SCHEDULE_URLS, UMICH_CSV, UMICH_HISTORY, UMICH_HOME, UMICH_INFO
|
||||
from alphapulse.calendar import europe
|
||||
from alphapulse.calendar.parsers import EASTERN, MONTHS, bea_release_link, parse_fed_calendar, parse_ics, parse_values, release_date, bls_periods, release_metadata
|
||||
from alphapulse.calendar.service import apply_bls_periods, record_revision, next_poll, save_raw, save_result, save_schedule, save_series
|
||||
from alphapulse.calendar.umich import current_result, document_links, monthly_series, parse_schedule, pdf_text
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.official_sources import FEEDS, HOSTS, feed_entries
|
||||
from alphapulse.core.redis import KEY_PREFIX, redis
|
||||
from alphapulse.models import EconomicRelease
|
||||
|
||||
logger = logging.getLogger("alphapulse.calendar")
|
||||
STATUS_KEY = f"{KEY_PREFIX}calendar:status"
|
||||
HISTORY_DAYS = 45
|
||||
|
||||
|
||||
def retry_after_at(value: str | None, now: datetime) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
value = value.strip()
|
||||
if value.isdecimal():
|
||||
return now + timedelta(seconds=int(value))
|
||||
at = parsedate_to_datetime(value)
|
||||
if at.tzinfo is None:
|
||||
raise ValueError("Retry-After 日期缺少时区")
|
||||
return at.astimezone(UTC)
|
||||
except (ValueError, OverflowError, TypeError) as exc:
|
||||
logger.warning("官方 Retry-After 无法解析,沿用既有轮询间隔:%s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def fed_month_urls(now: datetime) -> list[str]:
|
||||
month = now.astimezone(EASTERN).replace(day=1)
|
||||
following = (month + timedelta(days=32)).replace(day=1)
|
||||
names = {v: k.lower() for k, v in MONTHS.items()}
|
||||
return [f"https://www.federalreserve.gov/newsevents/{d.year}-{names[d.month]}.htm" for d in (month, following)]
|
||||
|
||||
|
||||
class CalendarWorker:
|
||||
def __init__(self) -> None:
|
||||
# 沿用已验证的 BLS TLS 设置;其余来源保持默认配置。
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.set_ciphers("DEFAULT")
|
||||
self.http = httpx.AsyncClient(timeout=8, follow_redirects=True, headers={"User-Agent": "AlphaPulse economic calendar"})
|
||||
self.bls = httpx.AsyncClient(timeout=8, follow_redirects=True, verify=ctx, headers={"User-Agent": "AlphaPulse economic calendar"})
|
||||
self.sources: dict[str, dict] = {}
|
||||
self.fetch_slots = asyncio.Semaphore(2)
|
||||
|
||||
async def fetch(self, source: str, url: str, *, query: dict | None = None) -> tuple[httpx.Response, int]:
|
||||
# 链接来自公开响应,限制到对应官方域名,避免外部内容扩大访问范围。
|
||||
allowed = {"bls": {"www.bls.gov"}, "bea": {"www.bea.gov", "apps.bea.gov"}, "fed": {"www.federalreserve.gov"},
|
||||
"umich": {"www.sca.isr.umich.edu", "data.sca.isr.umich.edu"}, **HOSTS}
|
||||
if urlparse(url).scheme != "https" or urlparse(url).hostname not in allowed[source]:
|
||||
raise ValueError("公告链接不是对应机构的官方 HTTPS 地址")
|
||||
async with self.fetch_slots:
|
||||
client = self.bls if source == "bls" else self.http
|
||||
response = await client.post(url, json=query) if query is not None else await client.get(url)
|
||||
if urlparse(str(response.url)).hostname not in allowed[source] or response.url.scheme != "https":
|
||||
raise ValueError("官方响应重定向到了不受支持的地址")
|
||||
now = datetime.now(UTC)
|
||||
async with SessionLocal() as session:
|
||||
raw_id = await save_raw(session, source, str(response.url), response.status_code,
|
||||
{k: v for k, v in response.headers.items() if k in ("content-type", "date", "last-modified", "etag", "retry-after")},
|
||||
response.content, now, method=response.request.method)
|
||||
await session.commit()
|
||||
return response, raw_id
|
||||
|
||||
async def schedules(self) -> None:
|
||||
now = datetime.now(UTC)
|
||||
urls = [(s, u) for s, u in SCHEDULE_URLS.items() if s != "fed"] + [("fed", u) for u in fed_month_urls(now)]
|
||||
urls += [("bls", f"https://www.bls.gov/schedule/news_release/{code}.htm") for code in ("cpi", "ppi", "empsit")]
|
||||
urls += [("umich", UMICH_INFO), ("umich", UMICH_CSV)]
|
||||
urls += list(europe.SCHEDULES.items())
|
||||
# 启动时明确各来源尚未就绪;月份滚动后移除不再检查的旧月状态。
|
||||
self.sources = {url: self.sources.get(url, {"source": source, "checked_at": now.isoformat(), "error": "尚未同步"})
|
||||
for source, url in urls}
|
||||
for source, url in urls:
|
||||
retry_at = self.sources[url].get("retry_at")
|
||||
if retry_at and datetime.fromisoformat(retry_at) > datetime.now(UTC):
|
||||
continue
|
||||
try:
|
||||
fetch_url = europe.eurostat_calendar_url(now) if source == "eurostat" else url
|
||||
response, raw_id = await self.fetch(source, fetch_url, query=europe.INSEE_QUERY if source == "insee" else None)
|
||||
response.raise_for_status()
|
||||
if source in europe.SCHEDULES:
|
||||
events = europe.parse_schedule(source, response.text)
|
||||
async with SessionLocal() as session:
|
||||
# 欧洲公开日程是滚动/分页窗口,缺失不等于撤回。
|
||||
await save_schedule(session, source, url, events, raw_id, datetime.now(UTC), withdraw_missing=False)
|
||||
await session.commit()
|
||||
elif url == UMICH_INFO:
|
||||
await self.umich_schedule(response.text, now)
|
||||
elif url == UMICH_CSV:
|
||||
points = [(p, v) for p, v in monthly_series(response.text) if p >= "2024-12" and p <= now.strftime("%Y-%m")]
|
||||
async with SessionLocal() as session:
|
||||
await save_series(session, "umich", "umich_sentiment", points, "final", raw_id, datetime.now(UTC))
|
||||
await session.commit()
|
||||
elif source == "bls" and url.endswith(".htm"):
|
||||
code = url.rsplit("/", 1)[1].split(".")[0]
|
||||
async with SessionLocal() as session:
|
||||
await apply_bls_periods(session, "jobs" if code == "empsit" else code, bls_periods(response.text), raw_id)
|
||||
await session.commit()
|
||||
else:
|
||||
events = parse_fed_calendar(response.text) if source == "fed" else parse_ics(response.text, source)
|
||||
async with SessionLocal() as session:
|
||||
await save_schedule(session, source, url, events, raw_id, datetime.now(UTC))
|
||||
await session.commit()
|
||||
self.sources[url] = {"source": source, "checked_at": datetime.now(UTC).isoformat(), "error": None}
|
||||
except (httpx.HTTPError, ValueError, KeyError, TypeError, IndexError, StopIteration) as exc:
|
||||
self.sources[url] = {"source": source, "checked_at": datetime.now(UTC).isoformat(), "error": str(exc)}
|
||||
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in (429, 503):
|
||||
retry_at = retry_after_at(exc.response.headers.get("Retry-After"), datetime.now(UTC))
|
||||
if retry_at is not None:
|
||||
self.sources[url]["retry_at"] = retry_at.isoformat()
|
||||
logger.exception("日程采集失败:%s", url)
|
||||
|
||||
async def umich_schedule(self, info: str, now: datetime) -> None:
|
||||
history, _ = await self.fetch("umich", UMICH_HISTORY)
|
||||
history.raise_for_status()
|
||||
links = document_links(history.text, UMICH_HISTORY, {"Historical Prelim/Final Release Dates"})
|
||||
if "Historical Prelim/Final Release Dates" not in links:
|
||||
raise ValueError("密歇根官方资料缺少历史发布日期 PDF")
|
||||
documents = list(links.values())
|
||||
yearly = document_links(info, UMICH_INFO, {f"{year} Release Dates" for year in (now.year, now.year + 1)})
|
||||
if f"{now.year} Release Dates" not in yearly:
|
||||
raise ValueError("密歇根官方资料缺少当年日程 PDF")
|
||||
documents.extend(yearly.values())
|
||||
events = {}
|
||||
raw_id = None
|
||||
for url in documents:
|
||||
response, raw_id = await self.fetch("umich", url)
|
||||
response.raise_for_status()
|
||||
parsed = await asyncio.to_thread(parse_schedule, await asyncio.to_thread(pdf_text, response.content))
|
||||
# 年度日程在历史表之后处理,以官方最新年度安排为准;同一稳定身份不创建重复事件。
|
||||
for event in parsed:
|
||||
events[event.external_id] = (event, raw_id)
|
||||
async with SessionLocal() as session:
|
||||
# 不按单份 PDF 撤回其他年度事件;合并日程一次同步,修订引用对应 PDF。
|
||||
existing = {r.external_id: r for r in await session.scalars(select(EconomicRelease).where(
|
||||
EconomicRelease.source == "umich"))}
|
||||
batches = {}
|
||||
for event, evidence in events.values():
|
||||
if event.at < datetime(2024, 12, 31, 16, tzinfo=UTC):
|
||||
continue
|
||||
# 每份 PDF 是部分日程,不能让 save_schedule 的撤回逻辑作用于其他事件。
|
||||
batches.setdefault(evidence, []).append(event)
|
||||
for evidence, batch in batches.items():
|
||||
await save_schedule(session, "umich", UMICH_INFO, batch, evidence, now, withdraw_missing=False)
|
||||
if raw_id is not None:
|
||||
for row in existing.values():
|
||||
if row.external_id not in events and row.scheduled_at > now and row.first_received_at is None and row.schedule_status != "withdrawn":
|
||||
row.schedule_status = "withdrawn"
|
||||
row.revision_no += 1
|
||||
record_revision(session, row, raw_id)
|
||||
await session.commit()
|
||||
|
||||
async def result_page(self, row: EconomicRelease) -> tuple[httpx.Response, int] | None:
|
||||
if row.source in europe.SCHEDULES:
|
||||
return await self.european_result(row)
|
||||
date = row.scheduled_at.astimezone(EASTERN).date()
|
||||
if row.source == "umich":
|
||||
response, raw_id = await self.fetch("umich", UMICH_HOME)
|
||||
response.raise_for_status()
|
||||
period, stage, _ = current_result(response.text)
|
||||
if period == row.period and stage == row.stage:
|
||||
return response, raw_id
|
||||
if period < row.period or (period == row.period and stage == "preliminary" and row.stage == "final"):
|
||||
return None
|
||||
raise ValueError("密歇根当前页已晚于所选历史期次,未取得当期公告")
|
||||
if row.source == "bls":
|
||||
code = "empsit" if row.family == "jobs" else row.family
|
||||
# 本机 BLS archive 路径返回 403,最新公告页可访问;下面必须核对正式发布日期。
|
||||
url = f"https://www.bls.gov/news.release/{code}.nr0.htm"
|
||||
elif row.source == "fed":
|
||||
url = f"https://www.federalreserve.gov/newsevents/pressreleases/monetary{date:%Y%m%d}a.htm"
|
||||
else:
|
||||
# 现场核验 BEA RSS 漏了已发布的 PCE;使用官方日程的公告链接,不依赖 RSS 推送时效。
|
||||
url = row.release_url
|
||||
if url is None:
|
||||
schedule, _ = await self.fetch("bea", "https://www.bea.gov/news/schedule/full")
|
||||
schedule.raise_for_status()
|
||||
url = bea_release_link(schedule.text, date, row.family)
|
||||
if url is None:
|
||||
return None
|
||||
response, raw_id = await self.fetch(row.source, url)
|
||||
if response.status_code == 404:
|
||||
return None # 预约地址尚未发布;保留原始响应,不宣称采集成功。
|
||||
response.raise_for_status()
|
||||
published = release_date(response.text).date()
|
||||
if row.source == "bls" and published < date:
|
||||
return None # 到点后官网仍缓存上一期,等待当期公告,不填旧值。
|
||||
if published != date:
|
||||
raise ValueError("官方公告日期与日历不符,历史公布值尚未取得")
|
||||
return response, raw_id
|
||||
|
||||
async def european_result(self, row: EconomicRelease) -> tuple[httpx.Response, int] | None:
|
||||
day = row.scheduled_at.astimezone(europe.ZoneInfo(europe.ZONES[row.source])).date()
|
||||
url = row.release_url
|
||||
if url is None and row.source == "ecb":
|
||||
index, _ = await self.fetch("ecb", f"https://www.ecb.europa.eu/press/govcdec/mopo/{day.year}/html/index_include.en.html")
|
||||
index.raise_for_status()
|
||||
sel = Selector(text=index.text)
|
||||
urls = [urljoin(str(index.url), u) for u in sel.css('a[href]::attr(href)').getall()
|
||||
if f"ecb.mp{day:%y%m%d}~" in u and u.endswith(".en.html")]
|
||||
url = next(iter(dict.fromkeys(urls)), None)
|
||||
elif url is None and row.source == "destatis":
|
||||
# 官方主题页在 RSS 滚出后仍保留最近公告,且提供英文公告的真实链接。
|
||||
paths = {"de_cpi": "Economy/Prices/Consumer-Price-Index", "de_gdp": "Economy/National-Accounts-Domestic-Product", "de_jobs": "Labour/Labour-Market/Employment"}
|
||||
index, _ = await self.fetch("destatis", f"https://www.destatis.de/EN/Themes/{paths[row.family]}/_node.html")
|
||||
index.raise_for_status()
|
||||
suffix = {"de_cpi": "_611.html", "de_gdp": "_811.html", "de_jobs": "_132.html"}[row.family]
|
||||
candidates = [urljoin("https://www.destatis.de/", u) for u in Selector(text=index.text).css('a[href]::attr(href)').getall()
|
||||
if "/Press/" in u and "/PE" in u and u.endswith(suffix)]
|
||||
for candidate in dict.fromkeys(candidates):
|
||||
response, raw_id = await self.fetch(row.source, candidate)
|
||||
response.raise_for_status()
|
||||
announcement = europe.parse_announcement(row.family, response.text)
|
||||
if announcement.at.date() == row.scheduled_at.date() and announcement.period == row.period and announcement.stage == row.stage:
|
||||
return response, raw_id
|
||||
return None
|
||||
elif url is None:
|
||||
entries = []
|
||||
for feed in FEEDS[row.source]:
|
||||
response, _ = await self.fetch(row.source, feed)
|
||||
response.raise_for_status()
|
||||
entries.extend(feed_entries(response.content, row.source))
|
||||
for entry in entries:
|
||||
target = europe.family_for(row.source, entry.title)
|
||||
if target and target[0] == row.family and entry.at.astimezone(europe.ZoneInfo(europe.ZONES[row.source])).date() == day:
|
||||
# 只有当期同日同系列才取原文;最终还要核对公告的统计期和阶段。
|
||||
url = entry.url
|
||||
break
|
||||
if url is None:
|
||||
return None
|
||||
response, raw_id = await self.fetch(row.source, url)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
if row.source == "ons" and "/releases/" in str(response.url):
|
||||
patterns = {"uk_cpi": "/bulletins/consumerpriceinflation/", "uk_gdp": "/bulletins/gdp", "uk_jobs": "/bulletins/uklabourmarket/"}
|
||||
matches = [urljoin(str(response.url), u) for u in Selector(text=response.text).css('a[href]::attr(href)').getall() if patterns[row.family] in u]
|
||||
unique = list(dict.fromkeys(matches))
|
||||
if len(unique) != 1:
|
||||
raise ValueError("ONS 发布页未唯一指向目标统计公报")
|
||||
response, raw_id = await self.fetch(row.source, unique[0])
|
||||
response.raise_for_status()
|
||||
announcement = europe.parse_announcement(row.family, response.text)
|
||||
if announcement.at.astimezone(europe.ZoneInfo(europe.ZONES[row.source])).date() != day:
|
||||
raise ValueError("欧洲官方公告日期与所选日程不符,未填入其他期次的数据")
|
||||
return response, raw_id
|
||||
|
||||
async def poll_one(self, release_id: int) -> None:
|
||||
async with SessionLocal() as session:
|
||||
row = await session.scalar(select(EconomicRelease).where(EconomicRelease.id == release_id).with_for_update())
|
||||
now = datetime.now(UTC)
|
||||
retry_at = None
|
||||
try:
|
||||
page = await self.result_page(row)
|
||||
if page is not None:
|
||||
response, raw_id = page
|
||||
if row.source in europe.SCHEDULES:
|
||||
announcement = europe.parse_announcement(row.family, response.text)
|
||||
period, stage, values = announcement.period, announcement.stage, announcement.values
|
||||
elif row.family == "umich":
|
||||
period, stage, values = current_result(response.text)
|
||||
else:
|
||||
values = parse_values(row.family, response.text)
|
||||
text = " ".join(Selector(text=response.text).css("h1,#bodytext").xpath("string(.)").getall())
|
||||
period, stage = release_metadata(row.family, text)
|
||||
save_result(session, row, values, str(response.url), raw_id, datetime.now(UTC), period=period, stage=stage)
|
||||
row.error = None
|
||||
except (httpx.HTTPError, ValueError, KeyError, TypeError, IndexError) as exc:
|
||||
row.error = f"{type(exc).__name__}: {exc}"
|
||||
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in (429, 503):
|
||||
retry_at = retry_after_at(exc.response.headers.get("Retry-After"), datetime.now(UTC))
|
||||
logger.exception("公布值采集失败:%s %s", row.family, row.scheduled_at)
|
||||
row.last_checked_at = now
|
||||
row.next_check_at = next_poll(row.scheduled_at, datetime.now(UTC), row.first_received_at is not None, bool(row.error))
|
||||
if retry_at is not None:
|
||||
row.next_check_at = max(row.next_check_at, retry_at)
|
||||
await session.commit()
|
||||
|
||||
async def results(self) -> None:
|
||||
now = datetime.now(UTC)
|
||||
async with SessionLocal() as session:
|
||||
ids = list(await session.scalars(select(EconomicRelease.id).where(
|
||||
EconomicRelease.schedule_status == "scheduled", EconomicRelease.scheduled_at <= now,
|
||||
EconomicRelease.scheduled_at >= now - timedelta(days=HISTORY_DAYS), EconomicRelease.next_check_at <= now,
|
||||
).order_by(EconomicRelease.scheduled_at.desc()).limit(8)))
|
||||
# 少量同时发布的不同报告独立获取,历史修订不阻塞当期公布。
|
||||
await asyncio.gather(*(self.poll_one(id_) for id_ in ids))
|
||||
|
||||
async def run(self) -> None:
|
||||
async def schedules_loop() -> None:
|
||||
while True:
|
||||
await self.schedules()
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
async def results_loop() -> None:
|
||||
while True:
|
||||
await self.results()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def heartbeat_loop() -> None:
|
||||
while True:
|
||||
await redis.set(STATUS_KEY, json.dumps({"at": datetime.now(UTC).isoformat(), "sources": self.sources}), ex=30)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
try:
|
||||
async with asyncio.TaskGroup() as tasks:
|
||||
for loop in (schedules_loop, results_loop, heartbeat_loop):
|
||||
tasks.create_task(loop())
|
||||
finally:
|
||||
await self.http.aclose()
|
||||
await self.bls.aclose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
asyncio.run(CalendarWorker().run())
|
||||
Reference in new issue
Block a user