Add official monthly ADP calendar collection

This commit is contained in:
eezd committed 2026-10-31 10:57:31 +08:00
1 parent 8d6cbeeb40
commit 4184bc68e6
14 files changed
+631 -9

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
"""Read monthly ADP releases; weekly NER Pulse estimates are a separate series."""
import re
from datetime import UTC, date, datetime, time, timedelta
from decimal import Decimal
from urllib.parse import urljoin, urlparse
from parsel import Selector
from alphapulse.calendar.archives import Announcement
from alphapulse.calendar.parsers import EASTERN, MONTHS, Scheduled, compact
INDEX = "https://mediacenter.adp.com/press-releases?keywords=National+Employment+Report"
TITLE = "ADP National Employment Report"
MONTH = "(?:" + "|".join(MONTHS) + ")"
NUMBER = r"-?\d{1,3}(?:,\d{3})*"
NEXT_RELEASE = re.compile(r"The (" + MONTH + r") (20\d\d) ADP[^.]*?National Employment Report[^.]*?will be released (?:at 8:15 a\.m\. ET on (" + MONTH + r" \d{1,2}, 20\d\d)|on (" + MONTH + r" \d{1,2}, 20\d\d) at 8:15 a\.m\. ET)")
def monthly_title(title: str) -> bool:
return bool(re.match(r"ADP National Employment Report: Private[- ]Sector Employment (?:Increased|Shed|Decreased|Unchanged)", title, re.I))
def release_links(body: str, base: str, start: date, end: date) -> tuple[list[str], str | None]:
sel = Selector(text=body)
rows = sel.css(".wd_item_list .wd_item")
if not rows:
raise ValueError("ADP 官方新闻索引缺少公告列表")
links, dates = [], []
for row in rows:
day = datetime.strptime(compact(row.css(".wd_date").xpath("string(.)").get() or ""), "%b %d, %Y").date()
dates.append(day)
title = compact(row.css(".wd_title").xpath("string(.)").get() or "")
if start <= day <= end and monthly_title(title):
url = urljoin(base, row.css(".wd_title a::attr(href)").get() or "")
if urlparse(url).scheme != "https" or urlparse(url).hostname != "mediacenter.adp.com" or "ADP-National-Employment-Report-" not in urlparse(url).path:
raise ValueError("ADP 公告链接不是官方月报地址")
links.append(url)
if dates != sorted(dates, reverse=True):
raise ValueError("ADP 官方新闻索引不再按公布日期倒序")
following = sel.css(".wd_page_next a::attr(href)").get()
# The index is newest first; continue through pages containing only weekly estimates.
following = urljoin(base, following) if following and min(dates) >= start else None
if following and (urlparse(following).hostname != "mediacenter.adp.com" or urlparse(following).path != "/press-releases" or urlparse(following).scheme != "https"):
raise ValueError("ADP 分页链接不属于官方新闻索引")
return list(dict.fromkeys(links)), following
def report(body: str) -> Announcement:
sel = Selector(text=body)
titles = sel.css(".wd_title").xpath("string(.)").getall()
texts = sel.css(".wd_news_body").xpath("string(.)").getall()
dates = sel.css(".wd_date").xpath("string(.)").getall()
if len(titles) != 1 or len(texts) != 1 or len(dates) != 1 or not monthly_title(compact(titles[0])):
raise ValueError("ADP 响应不是唯一的月度就业公告")
text = compact(texts[0])
period = re.search(r"(" + MONTH + r") (20\d\d) Report Highlights", text)
reading = re.search(r"Change in U\.S\. Private Employment:\s*(" + NUMBER + r")(?![\d,])", text)
if period is None or reading is None:
raise ValueError("ADP 月报缺少统计期或私营就业净变动")
month = MONTHS[period[1]]
statistical = date(int(period[2]), month, 1)
day = datetime.strptime(compact(dates[0]), "%b %d, %Y").date()
# Monthly reports explicitly announce the next release at 8:15 a.m. ET.
# Require that cadence in each document instead of guessing an unknown time.
if NEXT_RELEASE.search(text) is None:
raise ValueError("ADP 公告缺少明确的月报美东公布时刻")
if not 0 <= (day - statistical).days <= 45:
raise ValueError("ADP 统计月与公告日期不符")
title_month = re.search(r"Jobs in (" + MONTH + r")", compact(titles[0]), re.I)
if title_month is None or title_month[1].lower() != period[1].lower():
raise ValueError("ADP 标题与正文统计月不符")
headline = re.search(r"Employment (Increased by|Shed|Decreased by) (" + NUMBER + r") Jobs", compact(titles[0]), re.I)
headline_value = Decimal(headline[2].replace(",", "")) * (1 if headline[1].lower() == "increased by" else -1) if headline else None
if headline_value != Decimal(reading[1].replace(",", "")):
raise ValueError("ADP 标题与正文的就业净变动不符")
previous_month = (statistical - timedelta(days=1)).strftime("%B")
previous = re.search(r"The " + previous_month + r" total (?:number of )?jobs added was revised from " + NUMBER + r" to (" + NUMBER + r")(?![\d,])", text)
values = {"actual": format(Decimal(reading[1].replace(",", "")) / 10000, "f"),
"previous": format(Decimal(previous[1].replace(",", "")) / 10000, "f") if previous else None}
if previous:
values["previous_basis"] = "ADP 本次月报列示的上月修订净变动"
return Announcement(datetime.combine(day, time(8, 15), EASTERN).astimezone(UTC), statistical.strftime("%Y-%m"), None,
{"adp_jobs": values})
def next_release(body: str) -> Scheduled:
text = compact(" ".join(Selector(text=body).css(".wd_news_body").xpath("string(.)").getall()))
match = NEXT_RELEASE.search(text)
if match is None:
raise ValueError("ADP 公告缺少明确的下次月报日期与美东时刻")
day = datetime.strptime(match[3] or match[4], "%B %d, %Y").date()
period = date(int(match[2]), MONTHS[match[1]], 1)
if not 0 <= (day - period).days <= 45:
raise ValueError("ADP 下次公布日期与统计月不符")
current = re.search(r"(" + MONTH + r") (20\d\d) Report Highlights", text)
if current is None:
raise ValueError("ADP 缺少本次月报统计期")
expected_next = (date(int(current[2]), MONTHS[current[1]], 28) + timedelta(days=4)).replace(day=1)
if period != expected_next:
raise ValueError("ADP 下次月报不是相邻统计月")
published = compact(Selector(text=body).css(".wd_date").xpath("string(.)").get() or "")
if day <= datetime.strptime(published, "%b %d, %Y").date():
raise ValueError("ADP 下次月报日期不晚于本次公告")
return Scheduled(f"adp-{period:%Y-%m}", "adp_jobs", TITLE, datetime.combine(day, time(8, 15), EASTERN).astimezone(UTC), period=period.strftime("%Y-%m"))
def schedule(body: str, url: str) -> list[Scheduled]:
current = report(body)
return [Scheduled(f"adp-{current.period}", "adp_jobs", TITLE, current.at, period=current.period, release_url=url), next_release(body)]
+27 -2
View File
@@ -24,7 +24,7 @@ from parsel import Selector
from sqlalchemy import select, text
from alphapulse.calendar.archives import Announcement, parse_announcements, parse_umich_report
from alphapulse.calendar import dol, eia, europe
from alphapulse.calendar import adp, dol, eia, europe
from alphapulse.calendar.catalog import UMICH_CSV, UMICH_HISTORY, UMICH_HOME, UMICH_INFO, UMICH_REPORTS
from alphapulse.calendar.parsers import Scheduled, bea_family, compact
from alphapulse.calendar.service import record_revision, save_raw, save_result, save_schedule, save_series
@@ -39,6 +39,7 @@ HOSTS = {"bls": {"www.bls.gov", "www.dol.gov"}, "bea": {"www.bea.gov"},
US_SOURCES = frozenset(HOSTS)
HOSTS["dol"] = {"www.dol.gov", "oui.doleta.gov"}
HOSTS["eia"] = {"www.eia.gov", "ir.eia.gov"}
HOSTS["adp"] = {"mediacenter.adp.com"}
HOSTS.update(EUROPE_HOSTS)
@@ -202,6 +203,24 @@ async def fetch_documents(start: date, end: date, known: set[str], errors: list[
documents.append(await fetch("dol", dol.LATEST))
except (httpx.HTTPError, ValueError) as exc:
report_error("dol", dol.LATEST, exc)
if "adp" in selected:
url = adp.INDEX
visited = set()
while url:
try:
if url in visited:
raise ValueError("ADP 官方分页形成循环")
visited.add(url)
page = await fetch("adp", url)
links, following = adp.release_links(page.body.decode(), url, start, end)
for link in links:
if link not in known:
documents.append(await fetch("adp", link))
known.add(link)
url = following
except (httpx.HTTPError, ValueError) as exc:
report_error("adp", str(exc.request.url) if isinstance(exc, httpx.HTTPError) else url, exc)
break
if "eia" in selected:
try:
documents.append(await fetch("eia", eia.SCHEDULE))
@@ -310,7 +329,7 @@ async def run(start: date, end: date, *, commit: bool = False, stored_only: bool
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.in_(("umich", "dol", "eia")) | RawResponse.source_code.in_(EUROPE_HOSTS)),
RawResponse.source_code.in_(("umich", "dol", "eia", "adp")) | 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]
@@ -350,6 +369,12 @@ async def run(start: date, end: date, *, commit: bool = False, stored_only: bool
announcements.append((document, "umich", announcement, schedules[identity][0].title))
for document in sorted(documents, key=lambda d: d.captured_at):
try:
if document.source == "adp":
if "ADP-National-Employment-Report-" in urlparse(document.url).path:
announcement = adp.report(document.body.decode())
if begin <= announcement.at < finish:
announcements.append((document, "adp_jobs", announcement, adp.TITLE))
continue
if document.source == "eia":
if urlparse(document.url).path.endswith("/table1.csv"):
if eia_schedule is None:
@@ -39,6 +39,7 @@ INDICATORS = {
"umich_sentiment": Indicator("密歇根大学消费者信心指数", 3, "点"),
"initial_claims": Indicator("初请失业金人数(季调)", 4, "万人"),
"continued_claims": Indicator("续请失业金人数(季调)", 3, "万人"),
"adp_jobs": Indicator("ADP 私营部门就业净变动(月度)", 4, "万人"),
"eia_crude": Indicator("EIA 商业原油库存变动(不含战略储备)", 4, "万桶"),
"eia_gasoline": Indicator("EIA 汽油库存变动", 3, "万桶"),
"eia_distillate": Indicator("EIA 精炼油库存变动", 3, "万桶"),
@@ -53,6 +54,7 @@ FAMILIES = {
"umich": Family("US", "umich", ("umich_sentiment",)),
"initial_claims": Family("US", "dol", ("initial_claims",)),
"continued_claims": Family("US", "dol", ("continued_claims",)),
"adp_jobs": Family("US", "adp", ("adp_jobs",)),
"eia_stocks": Family("US", "eia", ("eia_crude", "eia_gasoline", "eia_distillate")),
}
STAGES = {"preliminary": "初值", "final": "终值", "initial": "首发值", "updated": "更新估计", "advance": "初值", "second": "二次估计", "third": "终值"}
+36 -4
View File
@@ -12,7 +12,7 @@ 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 dol, eia, europe
from alphapulse.calendar import adp, dol, eia, 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
@@ -64,7 +64,7 @@ class CalendarWorker:
# 链接来自公开响应,限制到对应官方域名,避免外部内容扩大访问范围。
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"}, "dol": {"www.dol.gov", "oui.doleta.gov"},
"eia": {"www.eia.gov", "ir.eia.gov"}, **HOSTS}
"eia": {"www.eia.gov", "ir.eia.gov"}, "adp": {"mediacenter.adp.com"}, **HOSTS}
if urlparse(url).scheme != "https" or urlparse(url).hostname not in allowed[source]:
raise ValueError("公告链接不是对应机构的官方 HTTPS 地址")
async with self.fetch_slots:
@@ -88,6 +88,7 @@ class CalendarWorker:
urls += list(europe.SCHEDULES.items())
urls += [("dol", dol.SCHEDULE)]
urls += [("eia", eia.SCHEDULE)]
urls += [("adp", adp.INDEX)]
# 启动时明确各来源尚未就绪;月份滚动后移除不再检查的旧月状态。
self.sources = {url: self.sources.get(url, {"source": source, "checked_at": now.isoformat(), "error": "尚未同步"})
for source, url in urls}
@@ -99,7 +100,17 @@ class CalendarWorker:
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 ("dol", "eia"):
if source == "adp":
links, _ = adp.release_links(response.text, url, datetime.min.date(), now.astimezone(EASTERN).date())
if not links:
raise ValueError("ADP 官方新闻索引缺少近期月报")
report, evidence = await self.fetch(source, links[0])
report.raise_for_status()
events = adp.schedule(report.text, str(report.url))
async with SessionLocal() as session:
await save_schedule(session, source, url, events, evidence, datetime.now(UTC), withdraw_missing=False)
await session.commit()
elif source in ("dol", "eia"):
events = (dol if source == "dol" else eia).schedule(response.text, now)
async with SessionLocal() as session:
await save_schedule(session, source, url, events, raw_id, datetime.now(UTC), withdraw_missing=False)
@@ -179,6 +190,24 @@ class CalendarWorker:
await session.commit()
async def result_page(self, row: EconomicRelease) -> tuple[httpx.Response, int] | None:
if row.source == "adp":
if row.release_url:
url = row.release_url
else:
index, _ = await self.fetch("adp", adp.INDEX)
index.raise_for_status()
links, _ = adp.release_links(index.text, adp.INDEX, row.scheduled_at.astimezone(EASTERN).date(), row.scheduled_at.astimezone(EASTERN).date())
if not links:
return None
if len(links) != 1:
raise ValueError("ADP 同日月度公告不唯一")
url = links[0]
response, raw_id = await self.fetch("adp", url)
response.raise_for_status()
announcement = adp.report(response.text)
if announcement.at != row.scheduled_at or announcement.period != row.period:
raise ValueError("ADP 公告时间或统计月与所选日程不符")
return response, raw_id
if row.source == "eia":
if self.eia_schedule_body is None:
schedule, _ = await self.fetch("eia", eia.SCHEDULE)
@@ -310,7 +339,10 @@ class CalendarWorker:
page = await self.result_page(row)
if page is not None:
response, raw_id = page
if row.source == "eia":
if row.source == "adp":
announcement = adp.report(response.text)
period, stage, values = announcement.period, announcement.stage, announcement.values
elif row.source == "eia":
announcement = eia.report(response.text, self.eia_schedule_body)
period, stage, values = announcement.period, announcement.stage, announcement.values
elif row.source == "dol":
@@ -19,7 +19,7 @@ FEEDS = {
"insee": ("https://www.insee.fr/fr/flux/3",),
}
REGIONS = {"wscn_news": "CN", "wscn_live": "CN", "cls_depth": "CN", "cls_telegraph": "CN", "coindesk": "GLOBAL",
"bls": "US", "bea": "US", "fed": "US", "umich": "US", "dol": "US", "eia": "US", "ecb": "EA", "eurostat": "EA", "boe": "GB", "ons": "GB", "destatis": "DE", "insee": "FR"}
"bls": "US", "bea": "US", "fed": "US", "umich": "US", "dol": "US", "eia": "US", "adp": "US", "ecb": "EA", "eurostat": "EA", "boe": "GB", "ons": "GB", "destatis": "DE", "insee": "FR"}
HOSTS = {"ecb": {"www.ecb.europa.eu"}, "eurostat": {"ec.europa.eu"}, "boe": {"www.bankofengland.co.uk"},
"ons": {"www.ons.gov.uk"}, "destatis": {"www.destatis.de"}, "insee": {"www.insee.fr"}}
LANGUAGES = {"destatis": "de", "insee": "fr"}