Add official DOL and EIA economic calendar feeds

Collect weekly claims and precise commercial petroleum inventory changes with official schedule exceptions, distinct reporting periods, raw evidence, and revisions.

Extend explicit history backfills without changing their default source scope. Report archive gaps and reject mismatched weeks instead of filling current values into history.
This commit is contained in:
eezd committed 2026-10-30 23:11:40 +08:00
1 parent 62d8c7eb65
commit 8d6cbeeb40
16 files changed
+956 -10

No files matched your search

@@ -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')")
+55 -2
View File
@@ -24,7 +24,7 @@ from parsel import Selector
from sqlalchemy import select, text
from alphapulse.calendar.archives import Announcement, parse_announcements, parse_umich_report
from alphapulse.calendar import 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, []))
@@ -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"] = "快报值"
+100
View File
@@ -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
+111
View File
@@ -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)
+4 -1
View File
@@ -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, {})
+45 -4
View File
@@ -12,7 +12,7 @@ from parsel import Selector
from sqlalchemy import select
from alphapulse.calendar.catalog import SCHEDULE_URLS, UMICH_CSV, UMICH_HISTORY, UMICH_HOME, UMICH_INFO
from alphapulse.calendar import 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":
@@ -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"}
+46
View File
@@ -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.
+49
View File
@@ -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.
+59
View File
@@ -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"

1 STUB_1 10/23/26 10/16/26 Difference Percent Change 10/24/25 Difference Percent Change
2 Crude Oil 693.208 695.200 -1.992 -0.300 825.063 -131.855 -16.000
3 Commercial (Excluding SPR) 410.625 412.517 -1.892 -0.500 415.966 -5.341 -1.300
4 Strategic Petroleum Reserve (SPR) 282.583 282.683 -0.100 0.000 409.097 -126.514 -30.900
5 Total Motor Gasoline 209.180 205.310 3.870 1.900 210.738 -1.558 -0.700
6 Reformulated 0.022 0.025 -0.003 -12.000 0.028 -0.006 -21.400
7 Conventional 14.103 13.710 0.393 2.900 15.235 -1.131 -7.400
8 Blending Components 195.055 191.575 3.480 1.800 195.476 -0.421 -0.200
9 Fuel Ethanol 24.523 24.558 -0.036 -0.100 22.367 2.156 9.600
10 Kerosene-Type Jet Fuel 42.217 42.606 -0.389 -0.900 41.420 0.797 1.900
11 Distillate Fuel Oil 102.824 104.286 -1.462 -1.400 112.189 -9.365 -8.300
12 15 ppm sulfur and Under 91.071 92.880 -1.809 -1.900 101.624 -10.553 -10.400
13 > 15 ppm to 500 ppm sulfur 4.423 4.239 0.183 4.300 3.515 0.907 25.800
14 > 500 ppm sulfur 7.331 7.167 0.164 2.300 7.050 0.281 4.000
15 Residual Fuel Oil 23.399 23.733 -0.334 -1.400 21.802 1.597 7.300
16 Propane/Propylene 105.735 105.846 -0.111 -0.100 105.681 0.054 0.100
17 Other Oils 294.053 296.705 -2.652 -0.900 338.582 -44.529 -13.200
18 Unfinished Oils 76.191 76.051 0.140 0.200 85.483 -9.293 -10.900
19 Total Stocks (Including SPR) 1,495.139 1,498.245 -3.106 -0.200 1,677.842 -182.703 -10.900
20 Total Stocks (Excluding SPR) 1,212.556 1,215.562 -3.006 -0.200 1,268.745 -56.189 -4.400
21 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
22 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
23 Crude Oil Supply (2) Alaska 482 512 -30 432 50 487 429 13.6 432 419 3.2
24 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
25 Crude Oil Supply (4) Transfers to Crude Oil Supply 540 537 3 463 77 517 411 25.6 539 524 2.8
26 Crude Oil Supply (5) Alaskan NGL production 47 46 1 47 0 45 46 -2.3 44 47 -6.7
27 Crude Oil Supply (6) Natural Gasoline, Condensate, and Unfinished Oils 493 491 2 416 78 472 366 29.1 495 477 3.7
28 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
29 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
30 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
31 Crude Oil Supply (10) Imports by SPR 0 0 0 0 0 0 0 0.0 0 0 0.0
32 Crude Oil Supply (11) Imports into SPR by Others 0 0 0 0 0 0 0 0.0 0 0 0.0
33 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
34 Crude Oil Supply (13) Stock Change (+/build; -/draw) -285 -480 196 -904 619 -639 65 � � -474 55 � �
35 Crude Oil Supply (14) Commercial Stock Change -270 -437 167 -980 709 -596 -21 � � -30 4 � �
36 Crude Oil Supply (15) SPR Stock Change -14 -43 29 76 -90 -42 86 � � -444 52 � �
37 Crude Oil Supply (16) Adjustment 189 47 142 -481 670 -165 44 � � -5 109 � �
38 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
39 Other Supply (18) Production 10,189 10,144 45 10,053 137 10,188 10,026 1.6 10,072 9,551 5.5
40 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
41 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
42 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
43 Other Supply (22) Other 390 390 0 305 85 390 306 27.7 319 297 7.5
44 Other Supply (23) Refinery Processing Gain 956 964 -7 918 39 960 940 2.2 986 977 0.9
45 Other Supply (24) Net Imports -6,359 -6,030 -329 -5,026 -1,334 -6,213 -5,344 � � -6,012 -5,012 � �
46 Other Supply (25) Imports 1,315 1,377 -62 1,640 -326 1,419 1,614 -12.1 1,548 1,731 -10.5
47 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
48 Other Supply (27) Stock Change (+/build; -/draw) -159 -770 611 -1,292 1,133 -538 -681 � � -241 116 � �
49 Other Supply (28) Adjustment 234 234 0 206 28 235 207 � � 224 212 � �
50 Other Supply (29) Transfers to Crude Oil Supply -540 -537 -3 -463 -77 -517 -411 25.6 -539 -524 �
51 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
52 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
53 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
54 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
55 Products Supplied (34) Residual Fuel Oil 372 431 -60 376 -4 323 225 43.6 308 281 9.8
56 Products Supplied (35) Propane/Propylene 760 1,020 -259 964 -204 941 962 -2.2 969 974 -0.5
57 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
58 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
59 
+143
View File
@@ -0,0 +1,143 @@
<html><body>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.<table class="basic-table schedule full-width align-left-col-2 align-left-col-3 align-left-col-4 align-left-col-5">
<thead>
<tr>
<th width="121" scope="col">Data for the week ending</th>
<th width="114" scope="col">Alternate release date</th>
<th width="71" scope="col">Release day</th>
<th width="100" scope="col">Release time</th>
<th width="155" scope="col">Holiday</th>
</tr>
</thead>
<tbody>
<tr class="past-holiday">
<th scope="row">December 27, 2024</th>
<td>January 2, 2025</td>
<td>Thursday</td>
<td>11:00 a.m.</td>
<td>New Year's Day</td>
</tr>
<tr class="past-holiday">
<th scope="row">January 17, 2025</th>
<td>January 23, 2025</td>
<td>Thursday</td>
<td>12:00 p.m.</td>
<td>Martin Luther King Jr. Day / Inauguration</td>
</tr>
<tr class="past-holiday">
<th scope="row">February 14, 2025</th>
<td>February 20, 2025</td>
<td>Thursday</td>
<td>12:00 p.m.</td>
<td>President's Day</td>
</tr>
<tr class="past-holiday">
<th scope="row">May 23, 2025</th>
<td>May 29, 2025</td>
<td>Thursday</td>
<td>12:00 p.m.</td>
<td>Memorial Day</td>
</tr>
<tr class="past-holiday">
<th scope="row">August 29, 2025</th>
<td>September 4, 2025</td>
<td>Thursday</td>
<td>12:00 p.m.</td>
<td>Labor Day</td>
</tr>
<tr class="past-holiday">
<th scope="row">October 10, 2025</th>
<td>October 16, 2025</td>
<td>Thursday</td>
<td>12:00 p.m.</td>
<td>Columbus Day</td>
</tr>
<tr class="past-holiday">
<th scope="row">November 7, 2025</th>
<td>November 13, 2025</td>
<td>Thursday</td>
<td>12:00 p.m.</td>
<td>Veterans Day</td>
</tr>
<tr class="past-holiday">
<th scope="row">December 19, 2025</th>
<td class="message-updated">December 29, 2025</td>
<td>Monday</td>
<td>5:00 p.m.</td>
<td>Christmas</td>
</tr>
<tr class="past-holiday">
<th scope="row">January 16, 2026</th>
<td>January 22, 2026</td>
<td>Thursday</td>
<td>12:00 p.m.</td>
<td>Martin Luther King Jr. Day</td>
</tr>
<tr class="past-holiday">
<th scope="row">February 13, 2026</th>
<td>February 19, 2026</td>
<td>Thursday</td>
<td>12:00 p.m.</td>
<td>President's Day</td>
</tr>
<tr class="past-holiday">
<th scope="row">May 22, 2026</th>
<td>May 28, 2026</td>
<td>Thursday</td>
<td>12:00 p.m.</td>
<td>Memorial Day</td>
</tr>
<tr class="past-holiday">
<th scope="row">September 4, 2026</th>
<td>September 10, 2026</td>
<td>Thursday</td>
<td>12:00 p.m.</td>
<td>Labor Day</td>
</tr>
<tr class="past-holiday">
<th scope="row">October 9, 2026</th>
<td>October 15, 2026</td>
<td>Thursday</td>
<td>12:00 p.m.</td>
<td>Columbus Day</td>
</tr>
<tr>
<th scope="row">November 6, 2026</th>
<td>November 12, 2026</td>
<td>Thursday</td>
<td>12:00 p.m.</td>
<td>Veterans Day</td>
</tr>
</tbody>
</table></body></html>
+1 -1
View File
@@ -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
+180
View File
@@ -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 = """<html><body>
Publication Schedule: The UI Weekly Claims News Release is published each week on Thursday morning at 8:30am EST.
<table><thead><tr><th>Release Date</th><th>Release Time</th></tr></thead><tbody>
<tr><td>Wednesday, November 25, 2026</td><td>8:30 AM EST</td></tr>
</tbody></table></body></html>"""
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 = '''<a href="/press/2025/010825.pdf">8</a><a href="/press/2025/010825.pdf">Duplicate</a>
<a href="https://example.com/press/2025/011625.pdf">Other host</a>
<a href="/press/2024/122624.pdf">Old</a>'''
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('<a href="/press/2025/010826.pdf">Bad year</a>', 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='<a href="/press/2025/010825.pdf">8</a>')
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"]
+125
View File
@@ -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'<a href="{url}">28</a><a href="https://example.com{urlparse(url).path}">Other host</a>'
assert eia.archive_links(body, date(2026, 10, 1), date(2026, 10, 30)) == [url]
assert eia.csv_link('<a href="download/table1.csv">CSV</a>', 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('<a href="other/table1.csv">Other file</a>', 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()
+1 -1
View File
@@ -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<string, string> = { bls: "BLS", bea: "BEA", fed: "美联储", umich: "密歇根大学", ecb: "欧洲央行", eurostat: "Eurostat", boe: "英国央行", ons: "ONS", destatis: "Destatis", insee: "INSEE" }
const sources: Record<string, string> = { 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(() => {