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:
1 parent
62d8c7eb65
commit
8d6cbeeb40
16 files changed
+955
-9
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')")
|
||||||
@@ -24,7 +24,7 @@ from parsel import Selector
|
|||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from alphapulse.calendar.archives import Announcement, parse_announcements, parse_umich_report
|
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.catalog import UMICH_CSV, UMICH_HISTORY, UMICH_HOME, UMICH_INFO, UMICH_REPORTS
|
||||||
from alphapulse.calendar.parsers import Scheduled, bea_family, compact
|
from alphapulse.calendar.parsers import Scheduled, bea_family, compact
|
||||||
from alphapulse.calendar.service import record_revision, save_raw, save_result, save_schedule, save_series
|
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"},
|
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"}}
|
"fed": {"www.federalreserve.gov"}, "umich": {"www.sca.isr.umich.edu", "data.sca.isr.umich.edu"}}
|
||||||
US_SOURCES = frozenset(HOSTS)
|
US_SOURCES = frozenset(HOSTS)
|
||||||
|
HOSTS["dol"] = {"www.dol.gov", "oui.doleta.gov"}
|
||||||
|
HOSTS["eia"] = {"www.eia.gov", "ir.eia.gov"}
|
||||||
HOSTS.update(EUROPE_HOSTS)
|
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]:
|
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")
|
sel = Selector(text=body, type="html")
|
||||||
links = []
|
links = []
|
||||||
for node in sel.css("a[href]"):
|
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 = [("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="),
|
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")]
|
("fed", "https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm")]
|
||||||
|
indexes += [("dol", dol.SCHEDULE)]
|
||||||
for source, initial in indexes:
|
for source, initial in indexes:
|
||||||
if source not in selected or source in stopped_sources:
|
if source not in selected or source in stopped_sources:
|
||||||
continue
|
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)
|
links, following = archive_links(source, index.body.decode("utf-8"), url, start, end)
|
||||||
if not links and url == initial:
|
if not links and url == initial:
|
||||||
raise ValueError("官方归档未识别到目标公告,覆盖范围尚未核实")
|
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:
|
for link in links:
|
||||||
if link in known:
|
if link in known:
|
||||||
continue
|
continue
|
||||||
@@ -186,6 +197,23 @@ async def fetch_documents(start: date, end: date, known: set[str], errors: list[
|
|||||||
except (httpx.HTTPError, ValueError) as exc:
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
report_error(source, str(exc.request.url) if isinstance(exc, httpx.HTTPError) else url, exc)
|
report_error(source, str(exc.request.url) if isinstance(exc, httpx.HTTPError) else url, exc)
|
||||||
break
|
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
|
from alphapulse.calendar.europe_archives import collect
|
||||||
for source in sorted(selected & EUROPE_HOSTS.keys()):
|
for source in sorted(selected & EUROPE_HOSTS.keys()):
|
||||||
try:
|
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.source_code.in_(selected), RawResponse.status == 200,
|
||||||
(RawResponse.url.contains("/news.release/") | RawResponse.url.contains("/newsroom/economicdata/") |
|
(RawResponse.url.contains("/news.release/") | RawResponse.url.contains("/newsroom/economicdata/") |
|
||||||
RawResponse.url.contains("/news/202") | RawResponse.url.contains("/pressreleases/monetary") |
|
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)
|
).distinct(RawResponse.source_code, RawResponse.url, RawResponse.body_sha256)
|
||||||
.order_by(RawResponse.source_code, RawResponse.url, RawResponse.body_sha256, RawResponse.id))).all()
|
.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]
|
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 {}))
|
documents += await fetch_documents(start, end, {d.url for d in documents}, errors, **({"sources": sources} if sources is not None else {}))
|
||||||
announcements = []
|
announcements = []
|
||||||
series_points = []
|
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 的文件名。
|
# 先处理历史表,再处理年度更正;重跑的稳定身份不依赖日程 PDF 的文件名。
|
||||||
parsed_schedules = []
|
parsed_schedules = []
|
||||||
reports = []
|
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))
|
announcements.append((document, "umich", announcement, schedules[identity][0].title))
|
||||||
for document in sorted(documents, key=lambda d: d.captured_at):
|
for document in sorted(documents, key=lambda d: d.captured_at):
|
||||||
try:
|
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.source == "umich":
|
||||||
if document.url == UMICH_CSV:
|
if document.url == UMICH_CSV:
|
||||||
series_points.append((document, [(p, v) for p, v in monthly_series(document.body.decode())
|
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
|
changed = 0
|
||||||
if commit:
|
if commit:
|
||||||
await ensure_quiet_window()
|
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 = {}
|
batches = {}
|
||||||
for event, document in schedules.values():
|
for event, document in schedules.values():
|
||||||
_, batch = batches.setdefault((document.url, document.captured_at), (document, []))
|
_, batch = batches.setdefault((document.url, document.captured_at), (document, []))
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ INDICATORS = {
|
|||||||
"gdp": Indicator("实际 GDP 年化季率", 4),
|
"gdp": Indicator("实际 GDP 年化季率", 4),
|
||||||
"fed_rate": Indicator("联邦基金利率目标上限", 5, rate_channel="inverse"),
|
"fed_rate": Indicator("联邦基金利率目标上限", 5, rate_channel="inverse"),
|
||||||
"umich_sentiment": Indicator("密歇根大学消费者信心指数", 3, "点"),
|
"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 = {
|
FAMILIES = {
|
||||||
"cpi": Family("US", "bls", ("cpi_mom", "cpi_yoy", "core_cpi_mom", "core_cpi_yoy")),
|
"cpi": Family("US", "bls", ("cpi_mom", "cpi_yoy", "core_cpi_mom", "core_cpi_yoy")),
|
||||||
@@ -46,6 +51,9 @@ FAMILIES = {
|
|||||||
"gdp": Family("US", "bea", ("gdp",)),
|
"gdp": Family("US", "bea", ("gdp",)),
|
||||||
"fomc": Family("US", "fed", ("fed_rate",)),
|
"fomc": Family("US", "fed", ("fed_rate",)),
|
||||||
"umich": Family("US", "umich", ("umich_sentiment",)),
|
"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 = {"preliminary": "初值", "final": "终值", "initial": "首发值", "updated": "更新估计", "advance": "初值", "second": "二次估计", "third": "终值"}
|
||||||
STAGES["flash"] = "快报值"
|
STAGES["flash"] = "快报值"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -187,6 +187,9 @@ def release_rows(row: EconomicRelease, now: datetime) -> list[dict]:
|
|||||||
country_name = COUNTRIES[family.country_code]
|
country_name = COUNTRIES[family.country_code]
|
||||||
period_name = ""
|
period_name = ""
|
||||||
if row.period is not None:
|
if row.period is not None:
|
||||||
|
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])}月"
|
period_name = f"第{row.period[-1]}季度" if "-Q" in row.period else f"{int(row.period[5:7])}月"
|
||||||
for key in family.indicators:
|
for key in family.indicators:
|
||||||
indicator = INDICATORS[key]
|
indicator = INDICATORS[key]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from parsel import Selector
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from alphapulse.calendar.catalog import SCHEDULE_URLS, UMICH_CSV, UMICH_HISTORY, UMICH_HOME, UMICH_INFO
|
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.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.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
|
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.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.bls = httpx.AsyncClient(timeout=8, follow_redirects=True, verify=ctx, headers={"User-Agent": "AlphaPulse economic calendar"})
|
||||||
self.sources: dict[str, dict] = {}
|
self.sources: dict[str, dict] = {}
|
||||||
|
self.eia_schedule_body: str | None = None
|
||||||
self.fetch_slots = asyncio.Semaphore(2)
|
self.fetch_slots = asyncio.Semaphore(2)
|
||||||
|
|
||||||
async def fetch(self, source: str, url: str, *, query: dict | None = None) -> tuple[httpx.Response, int]:
|
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"},
|
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]:
|
if urlparse(url).scheme != "https" or urlparse(url).hostname not in allowed[source]:
|
||||||
raise ValueError("公告链接不是对应机构的官方 HTTPS 地址")
|
raise ValueError("公告链接不是对应机构的官方 HTTPS 地址")
|
||||||
async with self.fetch_slots:
|
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 += [("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 += [("umich", UMICH_INFO), ("umich", UMICH_CSV)]
|
||||||
urls += list(europe.SCHEDULES.items())
|
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": "尚未同步"})
|
self.sources = {url: self.sources.get(url, {"source": source, "checked_at": now.isoformat(), "error": "尚未同步"})
|
||||||
for source, url in urls}
|
for source, url in urls}
|
||||||
@@ -95,7 +99,14 @@ class CalendarWorker:
|
|||||||
fetch_url = europe.eurostat_calendar_url(now) if source == "eurostat" else url
|
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, raw_id = await self.fetch(source, fetch_url, query=europe.INSEE_QUERY if source == "insee" else None)
|
||||||
response.raise_for_status()
|
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)
|
events = europe.parse_schedule(source, response.text)
|
||||||
async with SessionLocal() as session:
|
async with SessionLocal() as session:
|
||||||
# 欧洲公开日程是滚动/分页窗口,缺失不等于撤回。
|
# 欧洲公开日程是滚动/分页窗口,缺失不等于撤回。
|
||||||
@@ -168,6 +179,30 @@ class CalendarWorker:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
async def result_page(self, row: EconomicRelease) -> tuple[httpx.Response, int] | None:
|
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:
|
if row.source in europe.SCHEDULES:
|
||||||
return await self.european_result(row)
|
return await self.european_result(row)
|
||||||
date = row.scheduled_at.astimezone(EASTERN).date()
|
date = row.scheduled_at.astimezone(EASTERN).date()
|
||||||
@@ -275,7 +310,13 @@ class CalendarWorker:
|
|||||||
page = await self.result_page(row)
|
page = await self.result_page(row)
|
||||||
if page is not None:
|
if page is not None:
|
||||||
response, raw_id = page
|
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)
|
announcement = europe.parse_announcement(row.family, response.text)
|
||||||
period, stage, values = announcement.period, announcement.stage, announcement.values
|
period, stage, values = announcement.period, announcement.stage, announcement.values
|
||||||
elif row.family == "umich":
|
elif row.family == "umich":
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ FEEDS = {
|
|||||||
"insee": ("https://www.insee.fr/fr/flux/3",),
|
"insee": ("https://www.insee.fr/fr/flux/3",),
|
||||||
}
|
}
|
||||||
REGIONS = {"wscn_news": "CN", "wscn_live": "CN", "cls_depth": "CN", "cls_telegraph": "CN", "coindesk": "GLOBAL",
|
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"},
|
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"}}
|
"ons": {"www.ons.gov.uk"}, "destatis": {"www.destatis.de"}, "insee": {"www.insee.fr"}}
|
||||||
LANGUAGES = {"destatis": "de", "insee": "fr"}
|
LANGUAGES = {"destatis": "de", "insee": "fr"}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
+143
@@ -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>
|
||||||
@@ -282,7 +282,7 @@ def test_worker_initializes_all_sources_and_drops_expired_month_status(trading):
|
|||||||
worker = CalendarWorker()
|
worker = CalendarWorker()
|
||||||
worker.sources["https://www.federalreserve.gov/old-month.htm"] = {"error": "old"}
|
worker.sources["https://www.federalreserve.gov/old-month.htm"] = {"error": "old"}
|
||||||
async def unavailable(source, url, *, query=None):
|
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())
|
assert all(s["error"] for s in worker.sources.values())
|
||||||
raise httpx.ConnectError("source unavailable")
|
raise httpx.ConnectError("source unavailable")
|
||||||
worker.fetch = unavailable
|
worker.fetch = unavailable
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -23,7 +23,7 @@ const country = ref("")
|
|||||||
const impactLabels = { pending: "待公布", bullish: "利多", bearish: "利空", neutral: "影响较小", uncertain: "待判断" }
|
const impactLabels = { pending: "待公布", bullish: "利多", bearish: "利空", neutral: "影响较小", uncertain: "待判断" }
|
||||||
const countries = computed(() => data.value?.countries ?? [])
|
const countries = computed(() => data.value?.countries ?? [])
|
||||||
const labels = { scheduled: "待公布", waiting: "获取中", delayed: "取数延迟", released: "已公布", withdrawn: "已撤回", error: "采集异常", historical: "仅有日程", unavailable: "官方未公布" }
|
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 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 value = (v: string | null, unit: string) => v === null ? "—" : `${v}${unit}`
|
||||||
const groups = computed(() => {
|
const groups = computed(() => {
|
||||||
|
|||||||
Reference in new issue
Block a user