From 4184bc68e6ef5877abfb343f480896107d7458db Mon Sep 17 00:00:00 2001 From: eezd Date: Sat, 31 Oct 2026 10:57:31 +0800 Subject: [PATCH] Add official monthly ADP calendar collection --- .../20261031_f7a0bc354de2_adp_calendar.py | 25 +++ backend/src/alphapulse/calendar/adp.py | 109 ++++++++++++ backend/src/alphapulse/calendar/backfill.py | 29 ++- backend/src/alphapulse/calendar/catalog.py | 2 + backend/src/alphapulse/calendar/worker.py | 40 ++++- .../src/alphapulse/core/official_sources.py | 2 +- .../tests/fixtures/calendar/adp-2024-12.html | 68 ++++++++ .../tests/fixtures/calendar/adp-2025-06.html | 62 +++++++ .../tests/fixtures/calendar/adp-2025-11.html | 63 +++++++ .../tests/fixtures/calendar/adp-2026-09.html | 7 + .../tests/fixtures/calendar/adp-index.html | 64 +++++++ backend/tests/test_adp.py | 165 ++++++++++++++++++ backend/tests/test_calendar.py | 2 +- web/src/pages/calendar/index.vue | 2 +- 14 files changed, 631 insertions(+), 9 deletions(-) create mode 100644 backend/alembic/versions/20261031_f7a0bc354de2_adp_calendar.py create mode 100644 backend/src/alphapulse/calendar/adp.py create mode 100644 backend/tests/fixtures/calendar/adp-2024-12.html create mode 100644 backend/tests/fixtures/calendar/adp-2025-06.html create mode 100644 backend/tests/fixtures/calendar/adp-2025-11.html create mode 100644 backend/tests/fixtures/calendar/adp-2026-09.html create mode 100644 backend/tests/fixtures/calendar/adp-index.html create mode 100644 backend/tests/test_adp.py diff --git a/backend/alembic/versions/20261031_f7a0bc354de2_adp_calendar.py b/backend/alembic/versions/20261031_f7a0bc354de2_adp_calendar.py new file mode 100644 index 0000000..8525ca7 --- /dev/null +++ b/backend/alembic/versions/20261031_f7a0bc354de2_adp_calendar.py @@ -0,0 +1,25 @@ +"""Register the official monthly ADP calendar source.""" +from alembic import op +import sqlalchemy as sa + +revision = "f7a0bc354de2" +down_revision = "e6f9ab243cd1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + source = sa.table("sources", *[sa.column(key, kind) for key, kind in [ + ("code", sa.String), ("name", sa.String), ("list_url", sa.String), ("collector", sa.String), + ("enabled", sa.Boolean), ("interval_seconds", sa.Integer), ("backfill_hours", sa.Integer), + ("consecutive_failures", sa.Integer), + ]]) + op.bulk_insert(source, [{"code": "adp", "name": "ADP·月度私营就业报告", + "list_url": "https://mediacenter.adp.com/press-releases?keywords=National+Employment+Report", + "collector": "calendar", "enabled": True, "interval_seconds": 3600, + "backfill_hours": 72, "consecutive_failures": 0}]) + + +def downgrade() -> None: + # Existing raw responses prevent source deletion through their foreign key. + op.execute("DELETE FROM sources WHERE code = 'adp'") diff --git a/backend/src/alphapulse/calendar/adp.py b/backend/src/alphapulse/calendar/adp.py new file mode 100644 index 0000000..8501c14 --- /dev/null +++ b/backend/src/alphapulse/calendar/adp.py @@ -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)] diff --git a/backend/src/alphapulse/calendar/backfill.py b/backend/src/alphapulse/calendar/backfill.py index 765d580..4586400 100644 --- a/backend/src/alphapulse/calendar/backfill.py +++ b/backend/src/alphapulse/calendar/backfill.py @@ -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: diff --git a/backend/src/alphapulse/calendar/catalog.py b/backend/src/alphapulse/calendar/catalog.py index cf13da9..ff63215 100644 --- a/backend/src/alphapulse/calendar/catalog.py +++ b/backend/src/alphapulse/calendar/catalog.py @@ -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": "终值"} diff --git a/backend/src/alphapulse/calendar/worker.py b/backend/src/alphapulse/calendar/worker.py index b8f0f79..533f223 100644 --- a/backend/src/alphapulse/calendar/worker.py +++ b/backend/src/alphapulse/calendar/worker.py @@ -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": diff --git a/backend/src/alphapulse/core/official_sources.py b/backend/src/alphapulse/core/official_sources.py index ac9b2bc..9e7d40c 100644 --- a/backend/src/alphapulse/core/official_sources.py +++ b/backend/src/alphapulse/core/official_sources.py @@ -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"} diff --git a/backend/tests/fixtures/calendar/adp-2024-12.html b/backend/tests/fixtures/calendar/adp-2024-12.html new file mode 100644 index 0000000..7818365 --- /dev/null +++ b/backend/tests/fixtures/calendar/adp-2024-12.html @@ -0,0 +1,68 @@ + + +
ADP National Employment Report: Private Sector Employment Increased by 122,000 Jobs in December; Annual Pay was Up 4.6%
+
Jan 8, 2025
+
+

ROSELAND, N.J., Jan. 8, 2025 /PRNewswire/ -- Private sector employment increased by 122,000 jobs in December and annual pay was up 4.6 percent year-over-year, according to the December ADP® National Employment Report™ produced by ADP Research in collaboration with the Stanford Digital Economy Lab ("Stanford Lab"). The ADP National Employment Report is an independent measure and high-frequency view of the private-sector labor market based on actual, anonymized payroll data of more than 25 million U.S. employees.

+

The jobs report and pay insights use ADP's fine-grained anonymized and aggregated payroll data to provide a representative picture of the private-sector labor market. The report details the current month's total private employment change, and weekly job data from the previous month. Because the underlying ADP payroll databases are continuously updated, the report provides a high-frequency, near real-time measure of U.S. employment. This measure reflects the number of employees on ADP client payrolls (Payroll Employment) to provide a richer understanding of the labor market. ADP's pay measure uniquely captures the earnings of a cohort of almost 10 million employees over a 12-month period.

+

"The labor market downshifted to a more modest pace of growth in the final month of 2024, with a slowdown in both hiring and pay gains," said Nela Richardson, chief economist, ADP. "Health care stood out in the second half of the year, creating more jobs than any other sector."

+

December 2024 Report Highlights*

+

View the ADP National Employment Report and interactive charts at www.adpemploymentreport.com.

+

JOBS REPORT

+

Private employers added 122,000 jobs in December
Hiring slowed in several industries. Employment in manufacturing shrank for the third straight month.

+

Change in U.S. Private Employment:     122,000

+

Change by Industry Sector

+

- Goods-producing:     10,000

+

- Service-providing:     112,000

+

Change by U.S. Regions

+

- Northeast:     19,000

+

- Midwest:     7,000

+

- South:     19,000

+

- West:     82,000

+

Change by Establishment Size

+

- Small establishments:     5,000

+

- Medium establishments:      9,000

+

- Large establishments:     97,000

+

PAY INSIGHTS

+

Pay gains slowed in December
Year-over-year pay growth for job-stayers slowed to 4.6 percent, the slowest pace of gains since July 2021. Pay growth for job-changers was 7.1 percent, a slight decline from November.

+

Median Change in Annual Pay (ADP matched person sample)

+

- Job-Stayers     4.6%

+

- Job-Changers     7.1%

+

Median Change in Annual Pay for Job-Stayers by Industry Sector

+

- Goods-producing:                                                       

+

- Service-providing:                                               

+

Median Change in Annual Pay for Job-Stayers by Firm Size

+

- Small firms:                                                                

+

- Medium firms:                                                             

+

- Large firms:                                                                

+

To see Pay Insights by U.S. State, Gender, and Age for Job-Stayers, visit here:

+

* Sum of components may not equal total due to rounding.

+

The November total number of jobs added remained unchanged at 146,000. The historical data file and weekly data for the previous month are available at https://adpemploymentreport.com/.

+

To subscribe to monthly email alerts or obtain additional information about the ADP National Employment Report, including employment and pay data, interactive charts, methodology, and a calendar of release dates, please visit https://adpemploymentreport.com/.    

+

The January 2025 ADP National Employment Report will be released at 8:15 a.m. ET on February 5, 2025.

+

About the ADP® National Employment Report™
The ADP National Employment Report is an independent measure of the change in U.S. private employment and pay derived from actual, anonymized payroll data of client companies served by ADP, a leading provider of human capital management solutions. The report is produced by ADP Research in collaboration with the Stanford Digital Economy Lab.

+

The ADP National Employment Report is broadly distributed to the public each month, free of charge, as part of the company's commitment to offering deeper insights of the U.S. labor market and providing businesses and governments with a source of credible and valuable information.

+

About the ADP Research
The mission of ADP Research is to make the future of work more productive through data-driven discovery. Companies, workers, and policymakers rely on our finely tuned data and unique perspective to make informed decisions that impact workplaces around the world.

+

About ADP (NASDAQ – ADP)
Designing better ways to work through cutting-edge products, premium services and exceptional experiences that enable people to reach their full potential.  HR, Talent, Time Management, Benefits and Payroll. Informed by data and designed for people.   Learn more at ADP.com

+

ADP, the ADP logo, and Always Designing for People, ADP National Employment Report, ADP Research Institute and ADP Research are trademarks of ADP, Inc. All other marks are the property of their respective owners.

+

Copyright © 2025 ADP, Inc. All rights reserved.

+

ADP-Media

+

(PRNewsfoto/ADP, LLC)

+

 

+ +
+

+ + ADP Research (PRNewsfoto/ADP, Inc.) + +

+ +
+ + + + + + +

SOURCE ADP, Inc.

+ diff --git a/backend/tests/fixtures/calendar/adp-2025-06.html b/backend/tests/fixtures/calendar/adp-2025-06.html new file mode 100644 index 0000000..34c227e --- /dev/null +++ b/backend/tests/fixtures/calendar/adp-2025-06.html @@ -0,0 +1,62 @@ + + +
ADP National Employment Report: Private Sector Employment Shed 33,000 Jobs in June; Annual Pay was Up 4.4%
+
Jul 2, 2025
+
+

ROSELAND, N.J., July 2, 2025 /PRNewswire/ -- Private sector employment shed 33,000 jobs in June and annual pay was up 4.4 percent year-over-year, according to the June ADP National Employment Report® produced by ADP Research in collaboration with the Stanford Digital Economy Lab ("Stanford Lab"). The ADP National Employment Report is an independent measure and high-frequency view of the private-sector labor market based on actual, anonymized payroll data of more than 25 million U.S. employees.

+

The jobs report and pay insights use ADP's fine-grained anonymized and aggregated payroll data to provide a representative picture of the private-sector labor market. The report details the current month's total private employment change, and weekly job data from the previous month. Because the underlying ADP payroll databases are continuously updated, the report provides a high-frequency, near real-time measure of U.S. employment. This measure reflects the number of employees on ADP client payrolls (Payroll Employment) to provide a richer understanding of the labor market. As of January 2025, ADP's Pay Insights measure captures nearly 14.8 million individual pay change observations each month, up from nearly 10 million when it launched.

+

"Though layoffs continue to be rare, a hesitancy to hire and a reluctance to replace departing workers led to job losses last month," said Dr. Nela Richardson, chief economist, ADP. "Still, the slowdown in hiring has yet to disrupt pay growth."

+

June 2025 Report Highlights*

+

View the ADP National Employment Report and interactive charts at www.adpemploymentreport.com.

+

JOBS REPORT

+

Private employers shed 33,000 jobs in June
Job losses in professional and business services, and education and health services led the decline. Leisure and hospitality, and manufacturing showed gains.

+

Change in U.S. Private Employment:     -33,000

+

Change by Industry Sector
- Goods-producing:     32,000

+

- Service-providing:     -66,000

+

Change by U.S. Regions
- Northeast:     -3,000

+

- Midwest:     -24,000

+

- South:     13,000

+

- West:     -20,000

+

Change by Establishment Size
- Small establishments:     -47,000

+

- Medium establishments:     -15,000

+

- Large establishments:     30,000

+

PAY INSIGHTS

+

Pay gains held steady in June
Year-over-year pay growth for job-stayers was little changed for June at 4.4 percent compared to 4.5 percent in May. Pay growth for job-changers was 6.8 percent in June, down slightly from 7.0 percent last month.

+

Median Change in Annual Pay (ADP matched person sample)
- Job-Stayers     4.4%
- Job-Changers     6.8%

+

Median Change in Annual Pay for Job-Stayers by Industry Sector
- Goods-producing:

+

- Service-providing:

+

Median Change in Annual Pay for Job-Stayers by Firm Size
- Small firms:

+

- Medium firms: 

+

- Large firms: 

+

To see Pay Insights by U.S. State, Gender, and Age for Job-Stayers, visit here.

+

* Sum of components may not equal total due to rounding.

+

The May total number of jobs added was revised from 37,000 to 29,000. The historical data file and weekly data for the previous month are available at https://adpemploymentreport.com/.

+

To subscribe to monthly email alerts or obtain additional information about the ADP National Employment Report, including employment and pay data, interactive charts, methodology, and a calendar of release dates, please visit https://adpemploymentreport.com/.    

+

The July 2025 ADP National Employment Report will be released at 8:15 a.m. ET on July 30, 2025.

+

About the ADP National Employment Report®
The ADP National Employment Report is an independent measure of the change in U.S. private employment and pay derived from actual, anonymized payroll data of client companies served by ADP, a leading provider of human capital management solutions. The report is produced by ADP Research in collaboration with the Stanford Digital Economy Lab.

+

The ADP National Employment Report is broadly distributed to the public each month, free of charge, as part of the company's commitment to offering deeper insights of the U.S. labor market and providing businesses and governments with a source of credible and valuable information.

+

About the ADP Research
The mission of ADP Research is to make the future of work more productive through data-driven discovery. Companies, workers, and policymakers rely on our finely tuned data and unique perspective to make informed decisions that impact workplaces around the world.

+

About ADP (NASDAQ – ADP)
Designing better ways to work through cutting-edge products, premium services and exceptional experiences that enable people to reach their full potential.  HR, Talent, Time Management, Benefits and Payroll. Informed by data and designed for people.   Learn more at ADP.com

+

ADP, the ADP logo, and Always Designing for People, ADP National Employment Report, and ADP Research are trademarks of ADP, Inc. All other marks are the property of their respective owners.

+

Copyright © 2025 ADP, Inc. All rights reserved.

+

ADP-Media

+

 

+

(PRNewsfoto/ADP, LLC)

+

 

+ +
+

+ + ADP Research Institute, logo (PRNewsfoto/ADP, Inc.) + +

+ +
+ + + + + + +

SOURCE ADP, Inc.

+ diff --git a/backend/tests/fixtures/calendar/adp-2025-11.html b/backend/tests/fixtures/calendar/adp-2025-11.html new file mode 100644 index 0000000..96f7812 --- /dev/null +++ b/backend/tests/fixtures/calendar/adp-2025-11.html @@ -0,0 +1,63 @@ + + +
ADP National Employment Report: Private Sector Employment Shed 32,000 Jobs in November; Annual Pay was Up 4.4%
+
Dec 3, 2025
+
+

ROSELAND, N.J., Dec. 3, 2025 /PRNewswire/ -- Private sector employment shed 32,000 jobs in November and pay was up 4.4 percent year-over-year according to the November ADP National Employment Report® produced by ADP Research in collaboration with the Stanford Digital Economy Lab ("Stanford Lab").  

+

The ADP National Employment Report is an independent measure of the labor market based on the anonymized weekly payroll data of more than 26 million private-sector employees in the United States. ADP's Pay Insights captures over 15 million individual pay change observations each month. Together, the jobs report and pay insights use ADP's fine-grained data to provide a representative and high-frequency picture of the private-sector labor market.

+

"Hiring has been choppy of late as employers weather cautious consumers and an uncertain macroeconomic environment," said Dr. Nela Richardson, chief economist, ADP. "And while November's slowdown was broad-based, it was led by a pullback among small businesses."

+

November 2025 Report Highlights

+

View the ADP National Employment Report and interactive charts at www.adpemploymentreport.com.

+

JOBS REPORT

+

Private employers shed 32,000 jobs in November
Job creation has been flat during the second half of 2025 and pay growth has been on a downward trend. November hiring was particularly weak in manufacturing, professional and business services, information, and construction.

+

Change in U.S. Private Employment:     -32,000

+

Change by Industry

+

- Goods-producing:     -19,000

+

- Service-providing:     -13,000

+

Change by U.S. Regions

+

- Northeast:     -100,000

+

- Midwest:     45,000

+

- South:     -43,000

+

- West:     67,000

+

Change by Establishment Size

+

- Small establishments:     -120,000

+

- Medium establishments:     51,000

+

- Large establishments:     39,000

+

PAY INSIGHTS

+

Pay growth slowed in November
Year-over-year pay for job-stayers rose 4.4 percent, down from 4.5 percent growth in October. For job-changers, pay was up 6.3 percent, slowing from 6.7 percent growth the month prior.

+

Median Change in Annual Pay

+

- Job-stayers     4.4%
- Job-changers     6.3%

+

Median Change in Annual Pay for Job-Stayers by Industry

+

- Goods-producing:                                                    

+

- Service-providing:                                               

+

Median Change in Annual Pay for Job-Stayers by Firm Size

+

- Small firms:                                                                

+

- Medium firms:                                                             

+

- Large firms:                                                                

+

To see Pay Insights by U.S. State, Gender, and Age for Job-Stayers, visit here:

+

The October total number of jobs added was revised from 42,000 to 47,000.

+

For additional information about the ADP National Employment Report, including historical files, employment and pay data, methodology, and a calendar of release dates, please visit https://adpemploymentreport.com/.    

+

The December 2025 ADP National Employment Report will be released on January 7, 2025 at 8:15 a.m. ET.

+

About ADP Research 
The mission of ADP Research is to make the future of work more productive through data-driven discovery. Companies, workers, and policy makers rely on our finely tuned data and unique perspective to make informed decisions that impact workplaces around the world.

+

To subscribe to monthly email alerts or obtain additional information about ADP Research, including employment and pay data, methodology, and a calendar of release dates, please visit https://www.adpresearch.com.    

+

About ADP (NASDAQ: ADP)
ADP has been shaping the world of work with innovation and expertise for more than 75 years. As a global leader in HR and payroll solutions, ADP continuously works to solve business challenges for our clients and their workers, from simple, easy-to-use tools for small businesses to fully integrated platforms for global enterprises – and everything in between. Always Designing for People means we're focused on just that – people. We use our unmatched AI-driven insights and proven expertise to design innovative solutions that help people achieve greater success at work. More than 1.1 million clients across 140+ countries rely on ADP's exceptional service to support their people and drive their business forward. HR, Talent, Time Management, Benefits, Compliance, and Payroll. Learn more at ADP.com

+

ADP, the ADP logo, and Always Designing for People, ADP National Employment Report, and ADP Research are registered trademarks of ADP, Inc. All other marks are the property of their respective owners.

+

Copyright © 2025 ADP, Inc. All rights reserved.

+

ADP-Media

+

(PRNewsfoto/ADP, LLC)

+

 

+ +
+

+ + ADP Research (PRNewsfoto/ADP, Inc.) + +

+ +
+ + + + +

SOURCE ADP, Inc.

+ diff --git a/backend/tests/fixtures/calendar/adp-2026-09.html b/backend/tests/fixtures/calendar/adp-2026-09.html new file mode 100644 index 0000000..6f5ae54 --- /dev/null +++ b/backend/tests/fixtures/calendar/adp-2026-09.html @@ -0,0 +1,7 @@ + + +
ADP National Employment Report: Private-Sector Employment Increased by 90,000 Jobs in September
+
Sep 30, 2026
+
+

Base pay rose 3.2%; gross pay was up 4.7%

ROSELAND, N.J., Sept. 30, 2026 /PRNewswire/ -- Private-sector employment increased by 90,000 jobs in September, according to the ADP National Employment Report® produced by ADP Research in collaboration with the Stanford Digital Economy Lab ("Stanford Lab").

Base pay for private-sector workers rose 3.2% and gross pay was up 4.7% year over year, according to ADP Pay Insights.

The ADP National Employment Report is an independent measure of the labor market based on the anonymized weekly payroll data of more than 26 million private-sector employees in the United States. ADP Pay Insights uses payroll transaction data to compare the wage growth of individual workers over 12-month intervals, resulting in more than 14.7 million year-over-year pay-change observations each month.

Together, these reports use ADP's finely-grained data to provide a representative and high-frequency picture of the private-sector labor market.

"It's a strong report," said Dr. Nela Richardson, chief economist, ADP. "After a three-month slowdown, job creation rebounded and pay growth remained solid."

September 2026 Report Highlights

View the ADP National Employment Report and interactive charts at www.adpemploymentreport.com.

EMPLOYMENT REPORT

Private employers added 90,000 jobs in September
Hiring accelerated for the first time since May, led by education and health care and leisure and hospitality. Financial activities and professional and business services showed weakness.

Change in U.S. Private Employment:     90,000

Change by Sector

- Goods-producing:     31,000

- Service-providing:    59,000

Change by U.S. Regions

- Northeast:      56,000

- Midwest:     5,000

- South:     11,000

- West:      17,000

Change by Establishment Size

- Small establishments:      23,000

- Medium establishments:     54,000

- Large establishments:     14,000

PAY INSIGHTS
ADP Pay Insights provides base pay growth and gross pay growth data across worker mobility, demographics, sector, employer size, pay quartile, and 56 U.S. metropolitan areas. The report also now offers pay growth distribution and an interactive data platform.

Base pay growth was little changed in September
Pay rose 3.0 percent for job-stayers year over year, and 4.8 percent for job-changers.

Visit our interactive platform for more information.

Median Change in Base Pay

Median Change in Gross Pay

Median Change in Base Pay by Sector

- Goods-producing:

- Service-providing:

Median Change in Base Pay by Firm Size

- Medium firms:

- Large firms:

To see Pay Insights by U.S. Metro, Gender, Age, and Pay Quartile, please visit https://payinsights.adp.com/

The August total number of jobs added was revised from 38,000 to 36,000.

Beginning with this month's release, the National Employment Report incorporates first-quarter Quarterly Census of Employment and Wages (QCEW) data issued by the Bureau of Labor Statistics on August 28, 2026.

For additional information about the ADP National Employment Report, including historical files, employment and pay data, methodology, and a calendar of release dates, please visit https://adpemploymentreport.com/.

The October 2026 ADP National Employment Report will be released on November 4, 2026 at 8:15 a.m. ET.

About ADP Research 
The mission of ADP Research is to make the future of work more productive through data-driven discovery. Companies, workers, and policy makers rely on our finely tuned data and unique perspective to make informed decisions that impact workplaces around the world.

To subscribe to monthly email alerts or obtain additional information about ADP Research, including employment and pay data, methodology, and a calendar of release dates, please visit https://www.adpresearch.com.

About ADP (NASDAQ: ADP)
ADP has been shaping the world of work with innovation and expertise for more than 75 years. As a global leader in HR and payroll solutions, ADP continuously works to solve business challenges for our clients and their workers, from simple, easy-to-use tools for small businesses to fully integrated platforms for global enterprises – and everything in between. Always Designing for People means we're focused on just that – people. We use our unmatched AI-driven insights and proven expertise to design innovative solutions that help people achieve greater success at work. More than 1.1 million clients across 140+ countries rely on ADP's exceptional service to support their people and drive their business forward. HR, Talent, Time Management, Benefits, Compliance, and Payroll. Learn more at ADP.com

ADP, the ADP logo, and Always Designing for People, ADP National Employment Report, and ADP Research are registered trademarks of ADP, Inc. All other marks are the property of their respective owners.

Copyright © 2026 ADP, Inc. All rights reserved.

ADP-Media

 

ADP Research

SOURCE ADP, Inc.

+ diff --git a/backend/tests/fixtures/calendar/adp-index.html b/backend/tests/fixtures/calendar/adp-index.html new file mode 100644 index 0000000..87e0556 --- /dev/null +++ b/backend/tests/fixtures/calendar/adp-index.html @@ -0,0 +1,64 @@ + + + + + diff --git a/backend/tests/test_adp.py b/backend/tests/test_adp.py new file mode 100644 index 0000000..b7c37a1 --- /dev/null +++ b/backend/tests/test_adp.py @@ -0,0 +1,165 @@ +"""Verify monthly ADP identity, signed changes, revisions and publication matching.""" +import asyncio +from datetime import UTC, date, datetime +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest + +from alphapulse.calendar import adp + +FIXTURES = Path(__file__).parent / "fixtures/calendar" +SEPTEMBER = (FIXTURES / "adp-2026-09.html").read_text() +NEGATIVE = (FIXTURES / "adp-2025-06.html").read_text() +CROSS_YEAR = (FIXTURES / "adp-2024-12.html").read_text() +INDEX = (FIXTURES / "adp-index.html").read_text() + + +@pytest.mark.parametrize("body,period,actual,previous,at", [ + (SEPTEMBER, "2026-09", "9", "3.6", datetime(2026, 9, 30, 12, 15, tzinfo=UTC)), + (NEGATIVE, "2025-06", "-3.3", "2.9", datetime(2025, 7, 2, 12, 15, tzinfo=UTC)), + (CROSS_YEAR, "2024-12", "12.2", None, datetime(2025, 1, 8, 13, 15, tzinfo=UTC)), +]) +def test_official_monthly_readings_preserve_signs_revisions_and_eastern_time(body, period, actual, previous, at): + report = adp.report(body) + assert (report.period, report.at) == (period, at) + assert report.values["adp_jobs"]["actual"] == actual + assert report.values["adp_jobs"]["previous"] == previous + + +def test_next_release_uses_explicit_dates_and_both_published_sentence_orders(): + next_month = adp.next_release(SEPTEMBER) + assert next_month.period == "2026-10" + assert next_month.at == datetime(2026, 11, 4, 13, 15, tzinfo=UTC) + assert adp.next_release(NEGATIVE).at == datetime(2025, 7, 30, 12, 15, tzinfo=UTC) + assert adp.next_release(CROSS_YEAR).period == "2025-01" + events = adp.schedule(SEPTEMBER, "https://mediacenter.adp.com/report") + assert events[0].external_id == "adp-2026-09" + assert events[0].release_url == "https://mediacenter.adp.com/report" + assert events[1].release_url is None + + +@pytest.mark.parametrize("body", [ + "Access denied", + SEPTEMBER.replace("Report Highlights", "Other period"), + SEPTEMBER.replace("Change in U.S. Private Employment", "Other metric"), + SEPTEMBER.replace("Sep 30, 2026", "Sep 30, 2025"), + SEPTEMBER.replace("8:15 a.m.", "Time pending"), + SEPTEMBER.replace("September 2026 Report Highlights", "August 2026 Report Highlights"), + SEPTEMBER.replace("90,000 Jobs", "91,000 Jobs"), + SEPTEMBER.replace("Private-Sector Employment Increased", "Preliminary Estimate Employment Increased"), +], ids=["denied", "missing-period", "missing-reading", "wrong-year", "unknown-time", "wrong-month", "different-total", "weekly-report"]) +def test_changed_or_inconsistent_report_fails(body): + with pytest.raises(ValueError): + adp.report(body) + + +def test_revisions_to_another_month_are_not_used_as_previous(): + body = SEPTEMBER.replace("The August total", "The July total") + assert adp.report(body).values["adp_jobs"]["previous"] is None + + +def test_invalid_future_schedule_does_not_discard_independent_current_readings(): + body = (FIXTURES / "adp-2025-11.html").read_text() + assert adp.report(body).period == "2025-11" + assert adp.report(body).values["adp_jobs"]["actual"] == "-3.2" + with pytest.raises(ValueError, match="下次公布日期与统计月不符"): + adp.next_release(body) + with pytest.raises(ValueError, match="相邻统计月"): + adp.next_release(SEPTEMBER.replace("The October 2026 ADP", "The November 2026 ADP")) + + +def test_index_excludes_weekly_pulse_and_follows_actual_pagination(): + links, following = adp.release_links(INDEX, adp.INDEX, date(2025, 1, 1), date(2026, 10, 31)) + assert len(links) == 5 + assert all("Preliminary-Estimate" not in link for link in links) + assert following.endswith("&o=9") + links, following = adp.release_links(INDEX, adp.INDEX, date(2026, 10, 1), date(2026, 10, 31)) + assert links == [] + assert following is None + links, following = adp.release_links(INDEX, adp.INDEX, date(2025, 1, 1), date(2025, 12, 31)) + assert links == [] + assert following is not None + + +@pytest.mark.parametrize("body", ["Access denied", INDEX.replace("https://mediacenter.adp.com/2026-09-30", "https://example.com/2026-09-30")]) +def test_invalid_index_or_external_report_link_fails(body): + with pytest.raises(ValueError): + adp.release_links(body, adp.INDEX, date(2025, 1, 1), date(2026, 10, 31)) + + +@pytest.mark.parametrize("period,release_url,outcome", [("2026-09", "saved-report", "success"), ("2026-08", "saved-report", "error"), + ("2026-10", None, "pending"), ("2026-09", None, "success")]) +def test_worker_matches_actual_publication_and_statistical_month(trading, period, release_url, outcome): + from alphapulse.calendar.worker import CalendarWorker + + async def run(): + worker = CalendarWorker() + try: + async def fetch(source, url): + text = INDEX if url == adp.INDEX else SEPTEMBER + return httpx.Response(200, text=text, request=httpx.Request("GET", url if url.startswith("https") else "https://mediacenter.adp.com/report")), 1 + worker.fetch = fetch + row = SimpleNamespace(source="adp", period=period, release_url=release_url, + scheduled_at=datetime(2026, 11, 4, 13, 15, tzinfo=UTC) if period == "2026-10" else datetime(2026, 9, 30, 12, 15, tzinfo=UTC)) + if outcome == "error": + with pytest.raises(ValueError, match="所选日程不符"): + await worker.result_page(row) + else: + assert (await worker.result_page(row) is not None) == (outcome == "success") + finally: + await worker.http.aclose() + await worker.bls.aclose() + asyncio.run(run()) + + +def test_history_preview_parses_stored_monthly_evidence_without_writes(trading, monkeypatch): + from alphapulse.calendar import backfill + + class Session: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + pass + + async def execute(self, _): + return SimpleNamespace(all=lambda: []) + + async def scalars(self, _): + return [] + + doc = backfill.Document("adp", "https://mediacenter.adp.com/2026-09-30-ADP-National-Employment-Report-Private-Sector-Employment-Increased-by-90,000-Jobs-in-September", SEPTEMBER.encode(), datetime(2026, 10, 31, tzinfo=UTC)) + monkeypatch.setattr(backfill, "SessionLocal", Session) + monkeypatch.setattr(backfill, "fetch_documents", AsyncMock(return_value=[doc])) + save = AsyncMock() + monkeypatch.setattr(backfill, "save_announcement", save) + result = asyncio.run(backfill.run(date(2026, 9, 30), date(2026, 9, 30), sources={"adp"})) + assert result["announcements"] == {"adp_jobs": 1} + assert result["errors"] == [] + assert result["mode"] == "dry-run" + save.assert_not_awaited() + + +def test_history_discovery_continues_past_newer_pages_without_matching_reports(trading, monkeypatch): + from alphapulse.calendar import backfill + + historical = '' + calls = [] + + async def get(self, url, **kwargs): + calls.append(url) + body = CROSS_YEAR if "2025-01-08" in url else INDEX if url == adp.INDEX else historical + return httpx.Response(200, text=body, request=httpx.Request("GET", url)) + + monkeypatch.setattr(httpx.AsyncClient, "get", get) + monkeypatch.setattr(backfill, "ensure_quiet_window", AsyncMock()) + monkeypatch.setattr(backfill.asyncio, "sleep", AsyncMock()) + errors = [] + docs = asyncio.run(backfill.fetch_documents(date(2025, 1, 1), date(2025, 12, 31), set(), errors, sources={"adp"})) + assert errors == [] + assert len(docs) == 1 + assert len(calls) == 3 + assert adp.report(docs[0].body.decode()).period == "2024-12" diff --git a/backend/tests/test_calendar.py b/backend/tests/test_calendar.py index 1157e25..019c4d6 100644 --- a/backend/tests/test_calendar.py +++ b/backend/tests/test_calendar.py @@ -282,7 +282,7 @@ def test_worker_initializes_all_sources_and_drops_expired_month_status(trading): worker = CalendarWorker() worker.sources["https://www.federalreserve.gov/old-month.htm"] = {"error": "old"} async def unavailable(source, url, *, query=None): - assert len(worker.sources) == 17 + assert len(worker.sources) == 18 assert all(s["error"] for s in worker.sources.values()) raise httpx.ConnectError("source unavailable") worker.fetch = unavailable diff --git a/web/src/pages/calendar/index.vue b/web/src/pages/calendar/index.vue index 42669ba..1e04e05 100644 --- a/web/src/pages/calendar/index.vue +++ b/web/src/pages/calendar/index.vue @@ -23,7 +23,7 @@ const country = ref("") const impactLabels = { pending: "待公布", bullish: "利多", bearish: "利空", neutral: "影响较小", uncertain: "待判断" } const countries = computed(() => data.value?.countries ?? []) const labels = { scheduled: "待公布", waiting: "获取中", delayed: "取数延迟", released: "已公布", withdrawn: "已撤回", error: "采集异常", historical: "仅有日程", unavailable: "官方未公布" } -const sources: Record = { bls: "BLS", bea: "BEA", fed: "美联储", umich: "密歇根大学", ecb: "欧洲央行", eurostat: "Eurostat", boe: "英国央行", ons: "ONS", destatis: "Destatis", insee: "INSEE", dol: "美国劳工部 DOL", eia: "美国能源信息署 EIA" } +const sources: Record = { bls: "BLS", bea: "BEA", fed: "美联储", umich: "密歇根大学", ecb: "欧洲央行", eurostat: "Eurostat", boe: "英国央行", ons: "ONS", destatis: "Destatis", insee: "INSEE", dol: "美国劳工部 DOL", eia: "美国能源信息署 EIA", adp: "ADP" } const formatted = (time: string) => dayjs(time).tz(zone).format("MM-DD HH:mm:ss") const value = (v: string | null, unit: string) => v === null ? "—" : `${v}${unit}` const groups = computed(() => {