Add official monthly ADP calendar collection
This commit is contained in:
1 parent
8d6cbeeb40
commit
4184bc68e6
14 files changed
+631
-9
No files matched your search
@@ -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)]
|
||||
@@ -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": "终值"}
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user