diff --git a/backend/alembic/versions/20261030_e6f9ab243cd1_us_calendar_sources.py b/backend/alembic/versions/20261030_e6f9ab243cd1_us_calendar_sources.py
new file mode 100644
index 0000000..d647048
--- /dev/null
+++ b/backend/alembic/versions/20261030_e6f9ab243cd1_us_calendar_sources.py
@@ -0,0 +1,28 @@
+"""Register DOL and EIA calendar sources without enabling news crawlers."""
+from alembic import op
+import sqlalchemy as sa
+
+revision = "e6f9ab243cd1"
+down_revision = "d5e8fa132bc0"
+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": code, "name": name, "list_url": url,
+ "collector": "calendar", "enabled": True, "interval_seconds": 3600,
+ "backfill_hours": 72, "consecutive_failures": 0}
+ for code, name, url in [
+ ("dol", "美国劳工部·失业金周报", "https://oui.doleta.gov/unemploy/archive.asp"),
+ ("eia", "美国能源信息署·石油库存周报", "https://www.eia.gov/petroleum/supply/weekly/schedule.php"),
+ ]])
+
+
+def downgrade() -> None:
+ # Existing raw responses prevent source deletion through their foreign key.
+ op.execute("DELETE FROM sources WHERE code IN ('dol', 'eia')")
diff --git a/backend/src/alphapulse/calendar/backfill.py b/backend/src/alphapulse/calendar/backfill.py
index cacf700..765d580 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 europe
+from alphapulse.calendar import 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
@@ -37,6 +37,8 @@ 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["dol"] = {"www.dol.gov", "oui.doleta.gov"}
+HOSTS["eia"] = {"www.eia.gov", "ir.eia.gov"}
HOSTS.update(EUROPE_HOSTS)
@@ -77,6 +79,8 @@ def family_for(document: Document) -> str | None:
def archive_links(source: str, body: str, base: str, start: date, end: date) -> tuple[list[str], str | None]:
"""只跟随官方索引的实际链接,不按月份合成公告地址。"""
+ if source == "dol":
+ return dol.archive_links(body, start, end), None
sel = Selector(text=body, type="html")
links = []
for node in sel.css("a[href]"):
@@ -157,6 +161,7 @@ async def fetch_documents(start: date, end: date, known: set[str], errors: list[
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")]
+ indexes += [("dol", dol.SCHEDULE)]
for source, initial in indexes:
if source not in selected or source in stopped_sources:
continue
@@ -172,6 +177,12 @@ async def fetch_documents(start: date, end: date, known: set[str], errors: list[
links, following = archive_links(source, index.body.decode("utf-8"), url, start, end)
if not links and url == initial:
raise ValueError("官方归档未识别到目标公告,覆盖范围尚未核实")
+ if source == "dol":
+ years = {int(urlparse(link).path.split("/")[2]) for link in links}
+ absent = set(range(start.year, end.year + 1)) - years
+ if absent:
+ errors.append({"source": source, "url": url,
+ "error": f"DOL 归档索引未列出年份 {sorted(absent)},历史覆盖不完整"})
for link in links:
if link in known:
continue
@@ -186,6 +197,23 @@ async def fetch_documents(start: date, end: date, known: set[str], errors: list[
except (httpx.HTTPError, ValueError) as exc:
report_error(source, str(exc.request.url) if isinstance(exc, httpx.HTTPError) else url, exc)
break
+ if "dol" in selected and "dol" not in stopped_sources and dol.LATEST not in known:
+ try:
+ documents.append(await fetch("dol", dol.LATEST))
+ except (httpx.HTTPError, ValueError) as exc:
+ report_error("dol", dol.LATEST, exc)
+ if "eia" in selected:
+ try:
+ documents.append(await fetch("eia", eia.SCHEDULE))
+ index = await fetch("eia", eia.ARCHIVE)
+ for url in eia.archive_links(index.body.decode(), start, end):
+ page = await fetch("eia", url)
+ link = eia.csv_link(page.body.decode(), url)
+ if link not in known:
+ documents.append(await fetch("eia", link))
+ known.add(link)
+ except (httpx.HTTPError, ValueError) as exc:
+ report_error("eia", str(exc.request.url) if isinstance(exc, httpx.HTTPError) else eia.ARCHIVE, exc)
from alphapulse.calendar.europe_archives import collect
for source in sorted(selected & EUROPE_HOSTS.keys()):
try:
@@ -282,7 +310,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 == "umich") | RawResponse.source_code.in_(EUROPE_HOSTS)),
+ RawResponse.source_code.in_(("umich", "dol", "eia")) | 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]
@@ -292,6 +320,8 @@ async def run(start: date, end: date, *, commit: bool = False, stored_only: bool
documents += await fetch_documents(start, end, {d.url for d in documents}, errors, **({"sources": sources} if sources is not None else {}))
announcements = []
series_points = []
+ eia_schedules = [doc for doc in documents if doc.source == "eia" and doc.url == eia.SCHEDULE]
+ eia_schedule = max(eia_schedules, key=lambda doc: doc.captured_at) if eia_schedules else None
# 先处理历史表,再处理年度更正;重跑的稳定身份不依赖日程 PDF 的文件名。
parsed_schedules = []
reports = []
@@ -320,6 +350,25 @@ 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 == "eia":
+ if urlparse(document.url).path.endswith("/table1.csv"):
+ if eia_schedule is None:
+ raise ValueError("EIA 缺少已采集的公布规则和节假日例外表")
+ announcement = eia.report(document.body.decode(), eia_schedule.body.decode())
+ path = urlparse(document.url).path
+ date_match = re.search(r"/archive/20\d\d/(\d{4}_\d{2}_\d{2})/", path)
+ if date_match and datetime.strptime(date_match[1], "%Y_%m_%d").date() != announcement.at.astimezone(eia.EASTERN).date():
+ raise ValueError("EIA 报告统计周与归档公布日期不符")
+ if begin <= announcement.at < finish:
+ announcements.append((document, "eia_stocks", announcement, "Weekly Petroleum Status Report"))
+ continue
+ if document.source == "dol":
+ if document.body.startswith(b"%PDF"):
+ parsed = await asyncio.to_thread(dol.report, await asyncio.to_thread(pdf_text, document.body))
+ for family, announcement in parsed.items():
+ if begin <= announcement.at < finish:
+ announcements.append((document, family, announcement, "Unemployment Insurance Weekly Claims"))
+ continue
if document.source == "umich":
if document.url == UMICH_CSV:
series_points.append((document, [(p, v) for p, v in monthly_series(document.body.decode())
@@ -348,6 +397,10 @@ async def run(start: date, end: date, *, commit: bool = False, stored_only: bool
changed = 0
if commit:
await ensure_quiet_window()
+ if eia_schedule is not None and eia_schedule.raw_id is None:
+ async with SessionLocal() as session:
+ await save_raw(session, "eia", eia_schedule.url, 200, eia_schedule.headers, eia_schedule.body, eia_schedule.captured_at)
+ await session.commit()
batches = {}
for event, document in schedules.values():
_, batch = batches.setdefault((document.url, document.captured_at), (document, []))
diff --git a/backend/src/alphapulse/calendar/catalog.py b/backend/src/alphapulse/calendar/catalog.py
index c8185ad..cf13da9 100644
--- a/backend/src/alphapulse/calendar/catalog.py
+++ b/backend/src/alphapulse/calendar/catalog.py
@@ -37,6 +37,11 @@ INDICATORS = {
"gdp": Indicator("实际 GDP 年化季率", 4),
"fed_rate": Indicator("联邦基金利率目标上限", 5, rate_channel="inverse"),
"umich_sentiment": Indicator("密歇根大学消费者信心指数", 3, "点"),
+ "initial_claims": Indicator("初请失业金人数(季调)", 4, "万人"),
+ "continued_claims": Indicator("续请失业金人数(季调)", 3, "万人"),
+ "eia_crude": Indicator("EIA 商业原油库存变动(不含战略储备)", 4, "万桶"),
+ "eia_gasoline": Indicator("EIA 汽油库存变动", 3, "万桶"),
+ "eia_distillate": Indicator("EIA 精炼油库存变动", 3, "万桶"),
}
FAMILIES = {
"cpi": Family("US", "bls", ("cpi_mom", "cpi_yoy", "core_cpi_mom", "core_cpi_yoy")),
@@ -46,6 +51,9 @@ FAMILIES = {
"gdp": Family("US", "bea", ("gdp",)),
"fomc": Family("US", "fed", ("fed_rate",)),
"umich": Family("US", "umich", ("umich_sentiment",)),
+ "initial_claims": Family("US", "dol", ("initial_claims",)),
+ "continued_claims": Family("US", "dol", ("continued_claims",)),
+ "eia_stocks": Family("US", "eia", ("eia_crude", "eia_gasoline", "eia_distillate")),
}
STAGES = {"preliminary": "初值", "final": "终值", "initial": "首发值", "updated": "更新估计", "advance": "初值", "second": "二次估计", "third": "终值"}
STAGES["flash"] = "快报值"
diff --git a/backend/src/alphapulse/calendar/dol.py b/backend/src/alphapulse/calendar/dol.py
new file mode 100644
index 0000000..0f96cae
--- /dev/null
+++ b/backend/src/alphapulse/calendar/dol.py
@@ -0,0 +1,100 @@
+"""DOL weekly claims: use published schedules and the seasonally adjusted release."""
+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, publication_time
+from alphapulse.calendar.parsers import EASTERN, MONTHS, Scheduled, compact
+
+SCHEDULE = "https://oui.doleta.gov/unemploy/archive.asp"
+LATEST = "https://www.dol.gov/ui/data.pdf"
+FAMILIES = ("initial_claims", "continued_claims")
+MONTH_PATTERN = "(?:" + "|".join(MONTHS) + ")"
+
+
+def schedule(body: str, now: datetime) -> list[Scheduled]:
+ sel = Selector(text=body)
+ text = compact(sel.xpath("string(//body)").get() or "")
+ if not re.search(r"Publication Schedule:.*Thursday morning at 8:30am EST", text):
+ raise ValueError("DOL 缺少已验证的每周公布规则")
+ exceptions = {}
+ tables = [table for table in sel.css("table") if {compact(n.xpath("string(.)").get()) for n in table.css("th")} == {"Release Date", "Release Time"}]
+ if len(tables) != 1:
+ raise ValueError("DOL 缺少唯一的节假日例外表")
+ for row in tables[0].css("tbody tr"):
+ cells = [compact(n.xpath("string(.)").get()) for n in row.css("td")]
+ if len(cells) != 2 or not re.fullmatch(r"\w+, " + MONTH_PATTERN + r" \d{1,2}, 20\d\d", cells[0]):
+ raise ValueError("DOL 例外公布日期格式已变化")
+ day = datetime.strptime(cells[0], "%A, %B %d, %Y").date()
+ if cells[1] != "8:30 AM EST":
+ raise ValueError("DOL 例外公布时刻已变化")
+ thursday = day + timedelta(days=3 - day.weekday())
+ if thursday in exceptions:
+ raise ValueError("DOL 同周公布例外不唯一")
+ exceptions[thursday] = day
+ start = now.astimezone(EASTERN).date() - timedelta(days=45)
+ end = now.astimezone(EASTERN).date() + timedelta(days=90)
+ days = {start + timedelta(days=i) for i in range((end - start).days + 1)}
+ result = []
+ for thursday in sorted(day for day in days if day.weekday() == 3):
+ day = exceptions.get(thursday, thursday)
+ at = datetime.combine(day, time(8, 30), EASTERN).astimezone(UTC)
+ for family in FAMILIES:
+ result.append(Scheduled(f"dol-{family}-{day}", family, "Unemployment Insurance Weekly Claims", at))
+ return result
+
+
+def archive_links(body: str, start: date, end: date) -> list[str]:
+ links = []
+ for link in Selector(text=body).css("a[href]::attr(href)").getall():
+ url = urljoin(SCHEDULE, link)
+ parsed = urlparse(url)
+ match = re.fullmatch(r"/press/(20\d\d)/(\d{6})\.pdf", parsed.path)
+ if parsed.scheme != "https" or parsed.hostname != "oui.doleta.gov" or match is None:
+ continue
+ day = datetime.strptime(match[2], "%m%d%y").date()
+ if day.year != int(match[1]):
+ raise ValueError("DOL 归档路径年份与日期不符")
+ if start <= day <= end:
+ links.append(url)
+ if not links:
+ raise ValueError("DOL 归档未列出所选范围的报告,历史覆盖尚未确认")
+ return list(dict.fromkeys(links))
+
+
+def report(text: str) -> dict[str, Announcement]:
+ text = compact(text)
+ if "UNEMPLOYMENT INSURANCE WEEKLY CLAIMS" not in text:
+ raise ValueError("DOL 响应不是失业金周报")
+ at = publication_time(text)
+ match = re.search(r"SEASONALLY ADJUSTED DATA (.*?) UNADJUSTED DATA", text)
+ if match is None:
+ raise ValueError("DOL 缺少独立的季调数据正文")
+ adjusted = match[1]
+ initial = re.search(r"In the week ending (" + MONTH_PATTERN + r" \d{1,2}), the advance figure for seasonally adjusted initial claims was ([\d,]+)", adjusted)
+ continued = re.search(r"advance number for seasonally adjusted insured unemployment during the week ending (" + MONTH_PATTERN + r" \d{1,2}) was ([\d,]+)", adjusted)
+ if initial is None or continued is None:
+ raise ValueError("DOL 缺少初请或续请季调读数")
+ result = {}
+ for family, reading in zip(FAMILIES, (initial, continued), strict=True):
+ # Resolve year rollover from the official publication date, never from collection time.
+ period = datetime.strptime(f"{reading[1]}, {at.astimezone(EASTERN).year}", "%B %d, %Y").date()
+ published = at.astimezone(EASTERN).date()
+ if period > published:
+ period = period.replace(year=period.year - 1)
+ if not 0 <= (published - period).days <= 21:
+ raise ValueError("DOL 统计周与公布日期不符")
+ following = adjusted[reading.end():].split("The 4-week moving average", 1)[0]
+ previous = re.search(r"previous week's level was (?:revised (?:up|down) by [\d,]+ from [\d,]+ to|unrevised at) ([\d,]+)", following)
+ if previous is None:
+ previous = re.search(r"previous week's unrevised level of ([\d,]+)", following)
+ if previous is None:
+ raise ValueError("DOL 缺少本报告列示的可比前值")
+ value = {"actual": format(Decimal(reading[2].replace(",", "")) / 10000, "f"),
+ "previous": format(Decimal(previous[1].replace(",", "")) / 10000, "f"),
+ "previous_basis": "DOL 本次季调报告列示的上周值(可能已修订)"}
+ result[family] = Announcement(at, period.isoformat(), None, {family: value})
+ return result
diff --git a/backend/src/alphapulse/calendar/eia.py b/backend/src/alphapulse/calendar/eia.py
new file mode 100644
index 0000000..4256d04
--- /dev/null
+++ b/backend/src/alphapulse/calendar/eia.py
@@ -0,0 +1,111 @@
+"""EIA commercial inventory changes from the official, unrounded Table 1 CSV."""
+import csv
+from datetime import UTC, date, datetime, time, timedelta
+from decimal import Decimal
+from io import StringIO
+import re
+from urllib.parse import urljoin, urlparse
+
+from parsel import Selector
+
+from alphapulse.calendar.archives import Announcement
+from alphapulse.calendar.parsers import EASTERN, Scheduled, compact
+
+SCHEDULE = "https://www.eia.gov/petroleum/supply/weekly/schedule.php"
+ARCHIVE = "https://www.eia.gov/petroleum/supply/weekly/archive/"
+LATEST = "https://ir.eia.gov/wpsr/table1.csv"
+ROWS = {"Commercial (Excluding SPR)": "eia_crude", "Total Motor Gasoline": "eia_gasoline", "Distillate Fuel Oil": "eia_distillate"}
+
+
+def exceptions(body: str) -> dict[date, datetime]:
+ sel = Selector(text=body)
+ text = compact(sel.xpath("string(//body)").get() or "")
+ if "10:30 a.m. eastern time on Wednesday" not in text or "All times are eastern" not in text:
+ raise ValueError("EIA 缺少已验证的每周公布规则")
+ tables = sel.css("table.schedule")
+ if len(tables) != 1 or "Data for the week ending" not in tables[0].xpath("string(.)").get():
+ raise ValueError("EIA 缺少唯一的节假日例外表")
+ result = {}
+ for row in tables[0].css("tbody tr"):
+ cells = [compact(n.xpath("string(.)").get()) for n in row.css("th,td")]
+ if len(cells) != 5:
+ raise ValueError("EIA 例外日程列格式已变化")
+ period = datetime.strptime(cells[0], "%B %d, %Y").date()
+ day = datetime.strptime(cells[1], "%B %d, %Y").date()
+ clock = datetime.strptime(cells[3].replace("a.m.", "AM").replace("p.m.", "PM"), "%I:%M %p").time()
+ if cells[2] != day.strftime("%A") or period in result:
+ raise ValueError("EIA 例外日程星期或统计周不符")
+ result[period] = datetime.combine(day, clock, EASTERN).astimezone(UTC)
+ return result
+
+
+def release_at(period: date, changes: dict[date, datetime]) -> datetime:
+ if period.weekday() != 4:
+ raise ValueError("EIA 统计期不是周五")
+ return changes.get(period, datetime.combine(period + timedelta(days=5), time(10, 30), EASTERN).astimezone(UTC))
+
+
+def schedule(body: str, now: datetime) -> list[Scheduled]:
+ changes = exceptions(body)
+ start = now.astimezone(EASTERN).date() - timedelta(days=60)
+ result = []
+ for i in range(160):
+ period = start + timedelta(days=i)
+ if period.weekday() == 4:
+ result.append(Scheduled(f"eia-stocks-{period}", "eia_stocks", "Weekly Petroleum Status Report",
+ release_at(period, changes), period=period.isoformat()))
+ return result
+
+
+def archive_links(body: str, start: date, end: date) -> list[str]:
+ result = []
+ for link in Selector(text=body).css("a[href]::attr(href)").getall():
+ url = urljoin(ARCHIVE, link)
+ parsed = urlparse(url)
+ match = re.fullmatch(r"/petroleum/supply/weekly/archive/(20\d\d)/(\d{4}_\d{2}_\d{2})/wpsr_\2\.php", parsed.path)
+ if parsed.scheme != "https" or parsed.hostname != "www.eia.gov" or match is None:
+ continue
+ day = datetime.strptime(match[2], "%Y_%m_%d").date()
+ if day.year != int(match[1]):
+ raise ValueError("EIA 归档年份与日期不符")
+ if start <= day <= end:
+ result.append(url)
+ if not result:
+ raise ValueError("EIA 归档没有所选范围的已公布报告")
+ return list(dict.fromkeys(result))
+
+
+def csv_link(body: str, base: str) -> str:
+ links = [urljoin(base, link) for link in Selector(text=body).css("a[href]::attr(href)").getall()
+ if link == "download/table1.csv"]
+ if len(set(links)) != 1:
+ raise ValueError("EIA 归档未唯一列示 Table 1 CSV")
+ return links[0]
+
+
+def report(body: str, schedule_body: str) -> Announcement:
+ rows = csv.reader(StringIO(body))
+ header = next(rows, [])
+ if len(header) != 8 or header[0] != "STUB_1" or header[3] != "Difference":
+ raise ValueError("EIA Table 1 CSV 表头已变化")
+ period, previous = (datetime.strptime(value, "%m/%d/%y").date() for value in header[1:3])
+ if period - previous != timedelta(days=7):
+ raise ValueError("EIA Table 1 当前周与上周不连续")
+ values = {}
+ for row in rows:
+ if row and row[0] == "STUB_1":
+ break # Following tables use daily supply rates, not inventory levels.
+ if not row or row[0] not in ROWS:
+ continue
+ if len(row) != 8 or ROWS[row[0]] in values:
+ raise ValueError("EIA 库存数据行重复或缺列")
+ number = row[3].replace(",", "")
+ if not re.fullmatch(r"[+-]?\d+(?:\.\d+)?", number):
+ raise ValueError("EIA 库存变动不是有限数值")
+ change = Decimal(number)
+ # Table 1 inventories are million barrels; the calendar displays ten thousand barrels.
+ # Previous inventory levels are not the previous week's inventory CHANGE.
+ values[ROWS[row[0]]] = {"actual": format(change * 100, "f"), "previous": None}
+ if values.keys() != set(ROWS.values()):
+ raise ValueError("EIA 缺少商业原油、汽油或精炼油库存变动")
+ return Announcement(release_at(period, exceptions(schedule_body)), period.isoformat(), None, values)
diff --git a/backend/src/alphapulse/calendar/service.py b/backend/src/alphapulse/calendar/service.py
index 170a40f..2fb731b 100644
--- a/backend/src/alphapulse/calendar/service.py
+++ b/backend/src/alphapulse/calendar/service.py
@@ -187,7 +187,10 @@ def release_rows(row: EconomicRelease, now: datetime) -> list[dict]:
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])}月"
+ if len(row.period) == 10:
+ period_name = f"截至{row.period}当周"
+ else:
+ 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, {})
diff --git a/backend/src/alphapulse/calendar/worker.py b/backend/src/alphapulse/calendar/worker.py
index c21abff..b8f0f79 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 europe
+from alphapulse.calendar import 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
@@ -57,12 +57,14 @@ class CalendarWorker:
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.eia_schedule_body: str | None = None
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}
+ "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}
if urlparse(url).scheme != "https" or urlparse(url).hostname not in allowed[source]:
raise ValueError("公告链接不是对应机构的官方 HTTPS 地址")
async with self.fetch_slots:
@@ -84,6 +86,8 @@ class CalendarWorker:
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())
+ urls += [("dol", dol.SCHEDULE)]
+ urls += [("eia", eia.SCHEDULE)]
# 启动时明确各来源尚未就绪;月份滚动后移除不再检查的旧月状态。
self.sources = {url: self.sources.get(url, {"source": source, "checked_at": now.isoformat(), "error": "尚未同步"})
for source, url in urls}
@@ -95,7 +99,14 @@ 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 europe.SCHEDULES:
+ if 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)
+ await session.commit()
+ if source == "eia":
+ self.eia_schedule_body = response.text
+ elif source in europe.SCHEDULES:
events = europe.parse_schedule(source, response.text)
async with SessionLocal() as session:
# 欧洲公开日程是滚动/分页窗口,缺失不等于撤回。
@@ -168,6 +179,30 @@ class CalendarWorker:
await session.commit()
async def result_page(self, row: EconomicRelease) -> tuple[httpx.Response, int] | None:
+ if row.source == "eia":
+ if self.eia_schedule_body is None:
+ schedule, _ = await self.fetch("eia", eia.SCHEDULE)
+ schedule.raise_for_status()
+ eia.exceptions(schedule.text)
+ self.eia_schedule_body = schedule.text
+ response, raw_id = await self.fetch("eia", row.release_url or eia.LATEST)
+ response.raise_for_status()
+ announcement = eia.report(response.text, self.eia_schedule_body)
+ if announcement.at < row.scheduled_at or (announcement.at > row.scheduled_at and row.first_received_at is not None):
+ return None
+ if announcement.at != row.scheduled_at or announcement.period != row.period:
+ raise ValueError("EIA 公告统计周或时间与所选日程不符,未填入其他期次的数据")
+ return response, raw_id
+ if row.source == "dol":
+ response, raw_id = await self.fetch("dol", row.release_url or dol.LATEST)
+ response.raise_for_status()
+ announcements = await asyncio.to_thread(dol.report, await asyncio.to_thread(pdf_text, response.content))
+ announcement = announcements[row.family]
+ if announcement.at < row.scheduled_at or (announcement.at > row.scheduled_at and row.first_received_at is not None):
+ return None
+ if announcement.at != row.scheduled_at:
+ raise ValueError("DOL 公告时间与所选日程不符,未填入其他期次的数据")
+ return response, raw_id
if row.source in europe.SCHEDULES:
return await self.european_result(row)
date = row.scheduled_at.astimezone(EASTERN).date()
@@ -275,7 +310,13 @@ class CalendarWorker:
page = await self.result_page(row)
if page is not None:
response, raw_id = page
- if row.source in europe.SCHEDULES:
+ if 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":
+ announcement = (await asyncio.to_thread(dol.report, await asyncio.to_thread(pdf_text, response.content)))[row.family]
+ period, stage, values = announcement.period, announcement.stage, announcement.values
+ elif 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":
diff --git a/backend/src/alphapulse/core/official_sources.py b/backend/src/alphapulse/core/official_sources.py
index 3f51073..ac9b2bc 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", "ecb": "EA", "eurostat": "EA", "boe": "GB", "ons": "GB", "destatis": "DE", "insee": "FR"}
+ "bls": "US", "bea": "US", "fed": "US", "umich": "US", "dol": "US", "eia": "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/dol-2025-01-08.txt b/backend/tests/fixtures/calendar/dol-2025-01-08.txt
new file mode 100644
index 0000000..5480649
--- /dev/null
+++ b/backend/tests/fixtures/calendar/dol-2025-01-08.txt
@@ -0,0 +1,46 @@
+1
+
+News Release
+Connect with DOL at
+https://blog.dol.gov
+
+
+
+TRANSMISSION OF MATERIALS IN THIS RELEASE IS EMBARGOED UNTIL
+8:30 A.M. (Eastern) Wednesday, January 8, 2025
+
+UNEMPLOYMENT INSURANCE WEEKLY CLAIMS
+SEASONALLY ADJUSTED DATA
+In the week ending January 4, the advance figure for seasonally adjusted initial claims was 201,000, a decrease of
+10,000 from the previous week's unrevised level of 211,000. The 4-week moving average was 213,000, a decrease of
+10,250 from the previous week's unrevised average of 223,250.
+The advance seasonally adjusted insured unemployment rate was 1.2 percent for the week ending December 28,
+unchanged from the previous week's unrevised rate. The advance number for seasonally adjusted insured
+unemployment during the week ending December 28 was 1,867,000, an increase of 33,000 from the previous week's
+revised level. The previous week's level was revised down by 10,000 from 1,844,000 to 1,834,000. The 4-week moving
+average was 1,865,500, a decrease of 3,000 from the previous week's revised average. The previous week's average was
+revised down by 2,250 from 1,870,750 to 1,868,500.
+
+
+
+2
+
+
+UNADJUSTED DATA
+The advance number of actual initial claims under state programs, unadjusted, totaled 304,741 in the week ending
+January 4, an increase of 21,253 (or 7.5 percent) from the previous week. The seasonal factors had expected an increase
+of 35,647 (or 12.6 percent) from the previous week. There were 318,906 initial claims in the comparable week in 2024.
+The advance unadjusted insured unemployment rate was 1.4 percent during the week ending December 28, an increase of
+0.2 percentage point from the prior week. The advance unadjusted level of insured unemployment in state programs
+totaled 2,175,478, an increase of 316,122 (or 17.0 percent) from the preceding week. The seasonal factors had expected
+an increase of 276,886 (or 14.9 percent) from the previous week. A year earlier the rate was 1.4 percent and the volume
+was 2,104,272.
+
+
+The total number of continued weeks claimed for benefits in all programs for the week ending December 21 was
+1,886,283, a decrease of 88,519 from the previous week. There were 1,927,770 weekly claims filed for benefits in all
+programs in the comparable week in 2023.
+No state was triggered "on" the Extended Benefits program during the week ending December 21.
+Initial claims for UI benefits filed by former Federal civilian employees totaled 340 in the week ending December 28, a
+decrease of 166 from the prior week. There were 201 initial claims filed by newly discharged veterans, a decrease of 113
+from the preceding week.
diff --git a/backend/tests/fixtures/calendar/dol-current.txt b/backend/tests/fixtures/calendar/dol-current.txt
new file mode 100644
index 0000000..a32d1b4
--- /dev/null
+++ b/backend/tests/fixtures/calendar/dol-current.txt
@@ -0,0 +1,49 @@
+1
+
+News Release
+Connect with DOL at
+https://blog.dol.gov
+
+
+
+TRANSMISSION OF MATERIALS IN THIS RELEASE IS EMBARGOED UNTIL
+8:30 A.M. (Eastern) Thursday, October 29, 2026
+
+UNEMPLOYMENT INSURANCE WEEKLY CLAIMS
+SEASONALLY ADJUSTED DATA
+In the week ending October 24, the advance figure for seasonally adjusted initial claims was 194,000, a decrease of
+1,000 from the previous week's revised level. The previous week's level was revised up by 1,000 from 194,000 to
+195,000. The 4-week moving average was 196,750, a decrease of 1,250 from the previous week's revised average. The
+previous week's average was revised up by 250 from 197,750 to 198,000.
+The advance seasonally adjusted insured unemployment rate was 1.1 percent for the week ending October 17,
+unchanged from the previous week's unrevised rate. The advance number for seasonally adjusted insured
+unemployment during the week ending October 17 was 1,701,000, a decrease of 21,000 from the previous week's
+revised level. The previous week's level was revised down by 6,000 from 1,728,000 to 1,722,000. The 4-week moving
+average was 1,710,000, an increase of 500 from the previous week's revised average. The previous week's average was
+revised down by 1,500 from 1,711,000 to 1,709,500.
+
+
+
+2
+
+
+UNADJUSTED DATA
+The advance number of actual initial claims under state programs, unadjusted, totaled 178,851 in the week ending
+October 24, an increase of 5,648 (or 3.3 percent) from the previous week. The seasonal factors had expected an increase
+of 6,040 (or 3.5 percent) from the previous week. There were 203,012 initial claims in the comparable week in 2025.
+The advance unadjusted insured unemployment rate was 1.0 percent during the week ending October 17, unchanged
+from the prior week. The advance unadjusted level of insured unemployment in state programs totaled 1,490,071, an
+increase of 14,007 (or 0.9 percent) from the preceding week. The seasonal factors had expected an increase of 32,725 (or
+2.2 percent) from the previous week. A year earlier the rate was 1.1 percent and the volume was 1,708,691.
+
+
+The total number of continued weeks claimed for benefits in all programs for the week ending October 10 was
+1,500,078, an increase of 14,467 from the previous week. There were 1,712,123 weekly claims filed for benefits in all
+programs in the comparable week in 2025.
+No state was triggered "on" the Extended Benefits program during the week ending October 10.
+Initial claims for UI benefits filed by former Federal civilian employees totaled 442 in the week ending October 17, a
+decrease of 113 from the prior week. There were 387 initial claims filed by newly discharged veterans, a decrease of 40
+from the preceding week.
+There were 4,934 continued weeks claimed filed by former Federal civilian employees the week ending October 10, a
+decrease of 100 from the previous week. Newly discharged veterans claiming benefits totaled 5,875, an increase of 1,057
+from the prior week.
diff --git a/backend/tests/fixtures/calendar/eia-2026-10-23.csv b/backend/tests/fixtures/calendar/eia-2026-10-23.csv
new file mode 100644
index 0000000..aeb4e3f
--- /dev/null
+++ b/backend/tests/fixtures/calendar/eia-2026-10-23.csv
@@ -0,0 +1,59 @@
+"STUB_1","10/23/26","10/16/26","Difference","Percent Change","10/24/25","Difference","Percent Change"
+"Crude Oil","693.208","695.200","-1.992","-0.300","825.063","-131.855","-16.000"
+"Commercial (Excluding SPR)","410.625","412.517","-1.892","-0.500","415.966","-5.341","-1.300"
+"Strategic Petroleum Reserve (SPR)","282.583","282.683","-0.100","0.000","409.097","-126.514","-30.900"
+"Total Motor Gasoline","209.180","205.310","3.870","1.900","210.738","-1.558","-0.700"
+"Reformulated","0.022","0.025","-0.003","-12.000","0.028","-0.006","-21.400"
+"Conventional","14.103","13.710","0.393","2.900","15.235","-1.131","-7.400"
+"Blending Components","195.055","191.575","3.480","1.800","195.476","-0.421","-0.200"
+"Fuel Ethanol","24.523","24.558","-0.036","-0.100","22.367","2.156","9.600"
+"Kerosene-Type Jet Fuel","42.217","42.606","-0.389","-0.900","41.420","0.797","1.900"
+"Distillate Fuel Oil","102.824","104.286","-1.462","-1.400","112.189","-9.365","-8.300"
+"15 ppm sulfur and Under","91.071","92.880","-1.809","-1.900","101.624","-10.553","-10.400"
+"> 15 ppm to 500 ppm sulfur","4.423","4.239","0.183","4.300","3.515","0.907","25.800"
+"> 500 ppm sulfur","7.331","7.167","0.164","2.300","7.050","0.281","4.000"
+"Residual Fuel Oil","23.399","23.733","-0.334","-1.400","21.802","1.597","7.300"
+"Propane/Propylene","105.735","105.846","-0.111","-0.100","105.681","0.054","0.100"
+"Other Oils","294.053","296.705","-2.652","-0.900","338.582","-44.529","-13.200"
+"Unfinished Oils","76.191","76.051","0.140","0.200","85.483","-9.293","-10.900"
+"Total Stocks (Including SPR)","1,495.139","1,498.245","-3.106","-0.200","1,677.842","-182.703","-10.900"
+"Total Stocks (Excluding SPR)","1,212.556","1,215.562","-3.006","-0.200","1,268.745","-56.189","-4.400"
+"STUB_1","STUB_2","10/23/26","10/16/26","Difference","10/24/25","Difference","10/23/26","10/24/25","Percent Change","10/23/26","10/24/25","Percent Change"
+"Crude Oil Supply ","(1) Domestic Production","13,992","13,269","723","13,644","348","13,730","13,635","0.7","13,740","13,459","2.1"
+"Crude Oil Supply ","(2) Alaska","482","512","-30","432","50","487","429","13.6","432","419","3.2"
+"Crude Oil Supply ","(3) Lower 48","13,510","12,757","753","13,212","298","13,243","13,206","0.3","13,308","13,040","2.1"
+"Crude Oil Supply ","(4) Transfers to Crude Oil Supply","540","537","3","463","77","517","411","25.6","539","524","2.8"
+"Crude Oil Supply ","(5) Alaskan NGL production","47","46","1","47","0","45","46","-2.3","44","47","-6.7"
+"Crude Oil Supply ","(6) Natural Gasoline, Condensate, and Unfinished Oils","493","491","2","416","78","472","366","29.1","495","477","3.7"
+"Crude Oil Supply ","(7) Net Imports (Including SPR)","1,520","2,316","-796","690","830","1,847","1,569","17.7","1,910","2,211","-13.6"
+"Crude Oil Supply ","(8) Imports","6,374","5,758","616","5,051","1,323","6,255","5,724","9.3","6,165","6,101","1.0"
+"Crude Oil Supply ","(9) Commercial Crude Oil","6,374","5,758","616","5,051","1,323","6,255","5,724","9.3","6,165","6,101","1.0"
+"Crude Oil Supply ","(10) Imports by SPR","0","0","0","0","0","0","0","0.0","0","0","0.0"
+"Crude Oil Supply ","(11) Imports into SPR by Others","0","0","0","0","0","0","0","0.0","0","0","0.0"
+"Crude Oil Supply ","(12) Exports","4,854","3,442","1,412","4,361","493","4,408","4,155","6.1","4,254","3,891","9.3"
+"Crude Oil Supply ","(13) Stock Change (+/build; -/draw)","-285","-480","196","-904","619","-639","65","� �","-474","55","� �"
+"Crude Oil Supply ","(14) Commercial Stock Change","-270","-437","167","-980","709","-596","-21","� �","-30","4","� �"
+"Crude Oil Supply ","(15) SPR Stock Change","-14","-43","29","76","-90","-42","86","� �","-444","52","� �"
+"Crude Oil Supply ","(16) Adjustment","189","47","142","-481","670","-165","44","� �","-5","109","� �"
+"Crude Oil Supply ","(17) Crude Oil Input to Refineries","16,526","16,649","-123","15,219","1,307","16,567","15,594","6.2","16,658","16,247","2.5"
+"Other Supply ","(18) Production","10,189","10,144","45","10,053","137","10,188","10,026","1.6","10,072","9,551","5.5"
+"Other Supply ","(19) Natural Gas Plant Liquids","7,724","7,697","27","7,739","-14","7,755","7,693","0.8","7,677","7,208","6.5"
+"Other Supply ","(20) Renewable Fuels/Oxygenate Plant","1,508","1,484","25","1,396","112","1,473","1,393","5.7","1,410","1,367","3.1"
+"Other Supply ","(21) Fuel Ethanol","1,118","1,094","25","1,091","27","1,083","1,087","-0.4","1,090","1,070","1.9"
+"Other Supply ","(22) Other","390","390","0","305","85","390","306","27.7","319","297","7.5"
+"Other Supply ","(23) Refinery Processing Gain","956","964","-7","918","39","960","940","2.2","986","977","0.9"
+"Other Supply ","(24) Net Imports","-6,359","-6,030","-329","-5,026","-1,334","-6,213","-5,344","� �","-6,012","-5,012","� �"
+"Other Supply ","(25) Imports","1,315","1,377","-62","1,640","-326","1,419","1,614","-12.1","1,548","1,731","-10.5"
+"Other Supply ","(26) Exports","7,674","7,407","267","6,666","1,008","7,632","6,957","9.7","7,560","6,743","12.1"
+"Other Supply ","(27) Stock Change (+/build; -/draw)","-159","-770","611","-1,292","1,133","-538","-681","� �","-241","116","� �"
+"Other Supply ","(28) Adjustment","234","234","0","206","28","235","207","� �","224","212","� �"
+"Other Supply ","(29) Transfers to Crude Oil Supply","-540","-537","-3","-463","-77","-517","-411","25.6","-539","-524","�"
+"Products Supplied ","(30) Total","20,209","21,231","-1,022","21,281","-1,072","20,799","20,753","0.2","20,645","20,358","1.4"
+"Products Supplied ","(31) Finished Motor Gasoline","8,358","8,982","-625","8,924","-566","8,629","8,688","-0.7","8,766","8,782","-0.2"
+"Products Supplied ","(32) Kerosene-Type Jet Fuel","1,679","1,813","-133","1,948","-269","1,703","1,764","-3.5","1,725","1,716","0.5"
+"Products Supplied ","(33) Distillate Fuel Oil","4,207","3,732","475","3,580","627","3,978","4,002","-0.6","3,846","3,809","1.0"
+"Products Supplied ","(34) Residual Fuel Oil","372","431","-60","376","-4","323","225","43.6","308","281","9.8"
+"Products Supplied ","(35) Propane/Propylene","760","1,020","-259","964","-204","941","962","-2.2","969","974","-0.5"
+"Products Supplied ","(36) Other Oils","4,834","5,254","-420","5,489","-655","5,226","5,113","2.2","5,030","4,797","4.9"
+"Net Imports of Crude and Petroleum Products ","(33) Total","-4,839","-3,714","-1,125","-4,336","-503","-4,365","-3,774","15.7","-4,101","-2,801","46.4"
+
diff --git a/backend/tests/fixtures/calendar/eia-schedule.html b/backend/tests/fixtures/calendar/eia-schedule.html
new file mode 100644
index 0000000..eeff54f
--- /dev/null
+++ b/backend/tests/fixtures/calendar/eia-schedule.html
@@ -0,0 +1,143 @@
+
The standard release time and day of the week will be at 10:30 a.m. eastern time on Wednesday with the following exceptions. All times are eastern.
+
+
+ | Data for the week ending |
+ Alternate release date |
+ Release day |
+ Release time |
+ Holiday |
+
+
+
+
+
+
+
+
+ | December 27, 2024 |
+ January 2, 2025 |
+ Thursday |
+ 11:00 a.m. |
+ New Year's Day |
+
+
+
+
+
+ | January 17, 2025 |
+ January 23, 2025 |
+ Thursday |
+ 12:00 p.m. |
+ Martin Luther King Jr. Day / Inauguration |
+
+
+
+
+
+ | February 14, 2025 |
+ February 20, 2025 |
+ Thursday |
+ 12:00 p.m. |
+ President's Day |
+
+
+
+ | May 23, 2025 |
+ May 29, 2025 |
+ Thursday |
+ 12:00 p.m. |
+ Memorial Day |
+
+
+
+
+ | August 29, 2025 |
+ September 4, 2025 |
+ Thursday |
+ 12:00 p.m. |
+ Labor Day |
+
+
+
+ | October 10, 2025 |
+ October 16, 2025 |
+ Thursday |
+ 12:00 p.m. |
+ Columbus Day |
+
+
+
+ | November 7, 2025 |
+ November 13, 2025 |
+ Thursday |
+ 12:00 p.m. |
+ Veterans Day |
+
+
+
+ | December 19, 2025 |
+ December 29, 2025 |
+ Monday |
+ 5:00 p.m. |
+ Christmas |
+
+
+
+
+ | January 16, 2026 |
+ January 22, 2026 |
+ Thursday |
+ 12:00 p.m. |
+ Martin Luther King Jr. Day |
+
+
+
+
+
+ | February 13, 2026 |
+ February 19, 2026 |
+ Thursday |
+ 12:00 p.m. |
+ President's Day |
+
+
+
+ | May 22, 2026 |
+ May 28, 2026 |
+ Thursday |
+ 12:00 p.m. |
+ Memorial Day |
+
+
+
+
+ | September 4, 2026 |
+ September 10, 2026 |
+ Thursday |
+ 12:00 p.m. |
+ Labor Day |
+
+
+
+ | October 9, 2026 |
+ October 15, 2026 |
+ Thursday |
+ 12:00 p.m. |
+ Columbus Day |
+
+
+
+ | November 6, 2026 |
+ November 12, 2026 |
+ Thursday |
+ 12:00 p.m. |
+ Veterans Day |
+
+
+
+
+
+
+
+
+
diff --git a/backend/tests/test_calendar.py b/backend/tests/test_calendar.py
index b16edc5..1157e25 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) == 15
+ assert len(worker.sources) == 17
assert all(s["error"] for s in worker.sources.values())
raise httpx.ConnectError("source unavailable")
worker.fetch = unavailable
diff --git a/backend/tests/test_dol.py b/backend/tests/test_dol.py
new file mode 100644
index 0000000..d4ac0b9
--- /dev/null
+++ b/backend/tests/test_dol.py
@@ -0,0 +1,180 @@
+"""DOL regression coverage uses a public release extract and isolated I/O."""
+import asyncio
+from datetime import UTC, date, datetime, timedelta
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import httpx
+import pytest
+
+from alphapulse.calendar import dol
+
+REPORT = (Path(__file__).parent / "fixtures/calendar/dol-current.txt").read_text()
+SCHEDULE = """
+Publication Schedule: The UI Weekly Claims News Release is published each week on Thursday morning at 8:30am EST.
+| Release Date | Release Time |
+| Wednesday, November 25, 2026 | 8:30 AM EST |
+
"""
+
+
+def test_report_separates_statistical_weeks_and_revised_previous():
+ initial, continued = (dol.report(REPORT)[family] for family in dol.FAMILIES)
+ assert initial.at == continued.at == datetime(2026, 10, 29, 12, 30, tzinfo=UTC)
+ assert initial.period == "2026-10-24" and continued.period == "2026-10-17"
+ assert initial.values["initial_claims"]["actual"] == "19.4"
+ assert initial.values["initial_claims"]["previous"] == "19.5"
+ assert continued.values["continued_claims"]["actual"] == "170.1"
+ assert continued.values["continued_claims"]["previous"] == "172.2"
+ assert "修订" in continued.values["continued_claims"]["previous_basis"]
+
+
+def test_report_resolves_new_year_from_publication_date():
+ text = REPORT.replace("October 29, 2026", "January 2, 2027").replace("October 24", "December 26").replace("October 17", "December 19")
+ result = dol.report(text)
+ assert result["initial_claims"].period == "2026-12-26"
+ assert result["continued_claims"].period == "2026-12-19"
+ assert result["initial_claims"].at == datetime(2027, 1, 2, 13, 30, tzinfo=UTC)
+
+
+def test_historical_report_keeps_explicit_unrevised_previous():
+ # Source: https://oui.doleta.gov/press/2025/010825.pdf (first two pages).
+ text = (Path(__file__).parent / "fixtures/calendar/dol-2025-01-08.txt").read_text()
+ result = dol.report(text)
+ assert result["initial_claims"].at == datetime(2025, 1, 8, 13, 30, tzinfo=UTC)
+ assert result["initial_claims"].values["initial_claims"]["actual"] == "20.1"
+ assert result["initial_claims"].values["initial_claims"]["previous"] == "21.1"
+ assert result["continued_claims"].period == "2024-12-28"
+ assert result["continued_claims"].values["continued_claims"]["previous"] == "183.4"
+
+
+@pytest.mark.parametrize("text", [
+ "Access denied",
+ REPORT.replace("SEASONALLY ADJUSTED DATA", "CHANGED HEADER"),
+ REPORT.replace("initial claims was 194,000", "initial claims pending"),
+ REPORT.replace("October 24", "September 1"),
+ REPORT.replace("195,000.", "unavailable."),
+])
+def test_unrecognized_report_does_not_fabricate_values(text):
+ with pytest.raises(ValueError):
+ dol.report(text)
+
+
+def test_schedule_respects_dst_and_thanksgiving_exception():
+ events = dol.schedule(SCHEDULE, datetime(2026, 10, 30, tzinfo=UTC))
+ initial = {item.at.date(): item for item in events if item.family == "initial_claims"}
+ assert initial[date(2026, 10, 29)].at.hour == 12
+ assert initial[date(2026, 11, 5)].at.hour == 13
+ assert initial[date(2026, 11, 25)].at.hour == 13
+ assert date(2026, 11, 26) not in initial
+ assert all(item.period is None for item in events)
+ assert len({item.external_id for item in events}) == len(events)
+
+
+@pytest.mark.parametrize("body", [
+ SCHEDULE.replace("Thursday morning at 8:30am EST", "Schedule changed"),
+ SCHEDULE.replace("8:30 AM EST", "9:00 AM EST"),
+ SCHEDULE.replace("November 25, 2026", "11/25/2026"),
+ SCHEDULE.replace("Release Date", "Changed heading"),
+])
+def test_changed_schedule_or_exception_format_fails_explicitly(body):
+ with pytest.raises(ValueError):
+ dol.schedule(body, datetime(2026, 10, 30, tzinfo=UTC))
+
+
+def test_archives_only_follow_official_links_and_requested_range():
+ body = '''8Duplicate
+ Other host
+ Old'''
+ assert dol.archive_links(body, date(2025, 1, 1), date(2025, 12, 31)) == ["https://oui.doleta.gov/press/2025/010825.pdf"]
+ with pytest.raises(ValueError, match="历史覆盖尚未确认"):
+ dol.archive_links(body, date(2026, 1, 1), date(2026, 12, 31))
+ with pytest.raises(ValueError, match="年份"):
+ dol.archive_links('Bad year', date(2025, 1, 1), date(2026, 12, 31))
+
+
+@pytest.mark.parametrize("offset,received,expected", [(0, False, "page"), (7, False, "pending"), (-7, True, "pending"), (-7, False, "error")])
+def test_live_result_never_applies_another_week(trading, monkeypatch, offset, received, expected):
+ from alphapulse.calendar import worker as module
+
+ async def run():
+ worker = module.CalendarWorker()
+ try:
+ response = httpx.Response(200, content=b"synthetic PDF", request=httpx.Request("GET", dol.LATEST))
+ worker.fetch = AsyncMock(return_value=(response, 7))
+ monkeypatch.setattr(module, "pdf_text", lambda body: REPORT)
+ at = datetime(2026, 10, 29, 12, 30, tzinfo=UTC) + timedelta(days=offset)
+ row = SimpleNamespace(source="dol", family="initial_claims", release_url=None, scheduled_at=at,
+ first_received_at=at if received else None)
+ if expected == "error":
+ with pytest.raises(ValueError, match="公告时间"):
+ await worker.result_page(row)
+ else:
+ page = await worker.result_page(row)
+ assert (page is not None) == (expected == "page")
+ finally:
+ await worker.http.aclose()
+ await worker.bls.aclose()
+ asyncio.run(run())
+
+
+def test_weekly_rows_show_their_own_week_and_uncertain_crypto_impact(trading):
+ from alphapulse.calendar.service import release_rows
+ announcement = dol.report(REPORT)["continued_claims"]
+ row = SimpleNamespace(id=1, family="continued_claims", source="dol", title="Weekly claims",
+ period=announcement.period, stage=None, scheduled_at=announcement.at, schedule_status="scheduled",
+ first_received_at=announcement.at, last_checked_at=announcement.at, values=announcement.values,
+ error=None, release_url=dol.LATEST, schedule_url=dol.SCHEDULE, revision_no=2)
+ [item] = release_rows(row, announcement.at)
+ assert item["actual"] == "170.1" and item["unit"] == "万人"
+ assert "2026-10-17" in item["event_name"]
+ assert item["impact"] == "uncertain" and item["forecast"] is None
+
+
+def test_history_preview_includes_both_series_without_writing(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("dol", dol.LATEST, b"%PDF synthetic report", datetime(2026, 10, 30, tzinfo=UTC))
+ monkeypatch.setattr(backfill, "SessionLocal", Session)
+ monkeypatch.setattr(backfill, "fetch_documents", AsyncMock(return_value=[doc]))
+ monkeypatch.setattr(backfill, "pdf_text", lambda body: REPORT)
+ save = AsyncMock()
+ monkeypatch.setattr(backfill, "save_announcement", save)
+ result = asyncio.run(backfill.run(date(2026, 10, 29), date(2026, 10, 29), sources={"dol"}))
+ assert result["mode"] == "dry-run"
+ assert result["announcements"] == {"initial_claims": 1, "continued_claims": 1}
+ assert not result["errors"] and not result["missing_announcements"]
+ save.assert_not_awaited()
+
+
+def test_archive_year_gaps_are_explicit_and_current_report_is_still_collected(trading, monkeypatch):
+ from alphapulse.calendar import backfill
+ requests = []
+
+ def respond(request):
+ requests.append(str(request.url))
+ if request.url.path.endswith("archive.asp"):
+ return httpx.Response(200, text='8')
+ return httpx.Response(200, content=b"%PDF synthetic report")
+
+ client = httpx.AsyncClient
+ monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: client(**kwargs, transport=httpx.MockTransport(respond)))
+ monkeypatch.setattr(backfill, "ensure_quiet_window", AsyncMock())
+ monkeypatch.setattr(backfill.asyncio, "sleep", AsyncMock())
+ errors = []
+ documents = asyncio.run(backfill.fetch_documents(date(2025, 1, 1), date(2026, 10, 30), set(), errors, sources={"dol"}))
+ assert len(documents) == 2 and dol.LATEST in requests
+ assert len(errors) == 1 and "2026" in errors[0]["error"] and "不完整" in errors[0]["error"]
diff --git a/backend/tests/test_eia.py b/backend/tests/test_eia.py
new file mode 100644
index 0000000..1043895
--- /dev/null
+++ b/backend/tests/test_eia.py
@@ -0,0 +1,125 @@
+"""Verify EIA units, calendar exceptions, archives and reporting boundaries."""
+import asyncio
+from datetime import UTC, date, datetime, timedelta
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+from urllib.parse import urlparse
+
+import httpx
+import pytest
+
+from alphapulse.calendar import eia
+
+FIXTURES = Path(__file__).parent / "fixtures/calendar"
+CSV = (FIXTURES / "eia-2026-10-23.csv").read_text()
+SCHEDULE = (FIXTURES / "eia-schedule.html").read_text()
+
+
+def test_official_csv_preserves_precision_units_and_excludes_spr():
+ report = eia.report(CSV, SCHEDULE)
+ assert report.at == datetime(2026, 10, 28, 14, 30, tzinfo=UTC)
+ assert report.period == "2026-10-23"
+ assert report.values["eia_crude"]["actual"] == "-189.200"
+ assert report.values["eia_gasoline"]["actual"] == "387.000"
+ assert report.values["eia_distillate"]["actual"] == "-146.200"
+ assert all(v["previous"] is None for v in report.values.values())
+
+
+def test_schedule_uses_holiday_times_and_eastern_dst():
+ events = eia.schedule(SCHEDULE, datetime(2026, 10, 30, tzinfo=UTC))
+ by_period = {item.period: item.at for item in events}
+ assert by_period["2026-10-23"] == datetime(2026, 10, 28, 14, 30, tzinfo=UTC)
+ assert by_period["2026-10-30"] == datetime(2026, 11, 4, 15, 30, tzinfo=UTC)
+ assert by_period["2026-11-06"] == datetime(2026, 11, 12, 17, tzinfo=UTC)
+ assert eia.release_at(date(2025, 12, 19), eia.exceptions(SCHEDULE)) == datetime(2025, 12, 29, 22, tzinfo=UTC)
+
+
+@pytest.mark.parametrize("body", [
+ CSV.replace('"Difference"', '"Changed heading"', 1),
+ CSV.replace('"10/16/26"', '"10/15/26"', 1),
+ CSV.replace('"Commercial (Excluding SPR)"', '"Other stock"'),
+ CSV.replace('"-1.892"', '"NaN"', 1),
+])
+def test_unrecognized_csv_fails_instead_of_filling_other_data(body):
+ with pytest.raises(ValueError):
+ eia.report(body, SCHEDULE)
+
+
+def test_following_supply_table_does_not_override_inventory_data():
+ body = CSV + '\n"Commercial (Excluding SPR)",1,2,3,4,5,6,7'
+ assert eia.report(body, SCHEDULE).values == eia.report(CSV, SCHEDULE).values
+
+
+@pytest.mark.parametrize("body", ["Access denied", SCHEDULE.replace("10:30 a.m. eastern time on Wednesday", "Changed schedule"),
+ SCHEDULE.replace("12:00 p.m.", "Time pending", 1)])
+def test_unrecognized_schedule_fails(body):
+ with pytest.raises(ValueError):
+ eia.exceptions(body)
+
+
+def test_archive_discovery_follows_published_report_and_csv_links():
+ url = "https://www.eia.gov/petroleum/supply/weekly/archive/2026/2026_10_28/wpsr_2026_10_28.php"
+ body = f'28Other host'
+ assert eia.archive_links(body, date(2026, 10, 1), date(2026, 10, 30)) == [url]
+ assert eia.csv_link('CSV', url) == url.rsplit("/", 1)[0] + "/download/table1.csv"
+ with pytest.raises(ValueError):
+ eia.archive_links(body, date(2025, 1, 1), date(2025, 12, 31))
+ with pytest.raises(ValueError):
+ eia.csv_link('Other file', url)
+
+
+@pytest.mark.parametrize("offset,received,expected", [(0, False, "page"), (7, False, "pending"), (-7, True, "pending"), (-7, False, "error")])
+def test_worker_does_not_apply_latest_values_to_another_week(trading, offset, received, expected):
+ from alphapulse.calendar.worker import CalendarWorker
+
+ async def run():
+ worker = CalendarWorker()
+ try:
+ worker.eia_schedule_body = SCHEDULE
+ response = httpx.Response(200, text=CSV, request=httpx.Request("GET", eia.LATEST))
+ worker.fetch = AsyncMock(return_value=(response, 1))
+ at = datetime(2026, 10, 28, 14, 30, tzinfo=UTC) + timedelta(days=offset)
+ row = SimpleNamespace(source="eia", family="eia_stocks", period="2026-10-23", release_url=None,
+ scheduled_at=at, first_received_at=at if received else None)
+ if expected == "error":
+ with pytest.raises(ValueError, match="所选日程不符"):
+ await worker.result_page(row)
+ else:
+ result = await worker.result_page(row)
+ assert (result is not None) == (expected == "page")
+ finally:
+ await worker.http.aclose()
+ await worker.bls.aclose()
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("archive_date,valid", [("2026_10_28", True), ("2026_10_21", False)])
+def test_history_preview_requires_matching_archive_date_without_writes(trading, monkeypatch, archive_date, valid):
+ 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 []
+
+ now = datetime(2026, 10, 30, tzinfo=UTC)
+ docs = [backfill.Document("eia", eia.SCHEDULE, SCHEDULE.encode(), now),
+ backfill.Document("eia", f"https://www.eia.gov/petroleum/supply/weekly/archive/2026/{archive_date}/download/table1.csv", CSV.encode(), now)]
+ monkeypatch.setattr(backfill, "SessionLocal", Session)
+ monkeypatch.setattr(backfill, "fetch_documents", AsyncMock(return_value=docs))
+ save = AsyncMock()
+ monkeypatch.setattr(backfill, "save_announcement", save)
+ result = asyncio.run(backfill.run(date(2026, 10, 28), date(2026, 10, 28), sources={"eia"}))
+ assert result["mode"] == "dry-run"
+ assert result["announcements"].get("eia_stocks", 0) == int(valid)
+ assert bool(result["errors"]) != valid
+ save.assert_not_awaited()
diff --git a/web/src/pages/calendar/index.vue b/web/src/pages/calendar/index.vue
index 2ee7812..42669ba 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" }
+const sources: Record = { bls: "BLS", bea: "BEA", fed: "美联储", umich: "密歇根大学", ecb: "欧洲央行", eurostat: "Eurostat", boe: "英国央行", ons: "ONS", destatis: "Destatis", insee: "INSEE", dol: "美国劳工部 DOL", eia: "美国能源信息署 EIA" }
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(() => {