feat(data): corpus/dmsk 消费接口(spec §20.11.3 实施,设计已入档)——partition_reader 通用读取核(核内零语义:不做 as-of/PIT+零静默去重;失败分级:缺数据空 df 不 raise/跨分区列集与类型漂移 PartitionSchemaError fail loud;双机根解析 SANGUO_CORPUS_ROOT/SANGUO_FUND_DMSK_ROOT env 覆盖+水位线读取)+corpus_reader(get_news_meta/get_ann_meta symbols 下推 stock_code/sec_code/get_news_fulltext(ids) art_code 批量/ann_pdf_dir;schema 契约 09-17 NAS 实测落 docstring:稳定 id=art_code/announcement_id 与 PDF 同名)+fund_dmsk_reader(get_domain 五域+noticed_by 纯过滤 NOTICE_DATE 缺列 fail loud;契约:五域 NOTICE_DATE 实测全在/SECUCODE 带后缀 SECURITY_CODE 裸 6 位/单位元);TDD 23 例,全套 414 绿 [vps] [no-doc]
CI/CD / test (push) Successful in 32s
CI/CD / nas-deploy (push) Successful in 20s
CI/CD / nas-verify (push) Successful in 2s

This commit is contained in:
2026-09-17 23:44:02 +08:00
parent 7e5e720941
commit 1b942c0a9b
6 changed files with 424 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
"""corpus 语料薄封装(spec §20.11.3)。列名随源直存零转换(§15.2 双层读法)。
schema 契约(2026-09-17 NAS 实测落档,§20.11.3 数据契约③):
- news_meta: art_code(稳定 id)/stock_code(裸 6 位)/show_time/title/summary/
media_name/url/first_seen_date
- news_fulltext: art_code(=news_meta join 键)/url/show_time/content_text/fetch_date
- ann_meta: announcement_id(稳定 id,与 ann_pdf/<year>/<id>.pdf 同名)/
sec_code(裸 6 位)/sec_name/title/short_title/ann_time/ann_type(|| 分隔多码)/
ann_type_name/column_id/adjunct_url/adjunct_size/first_seen_date
- ann_pdf: <corpus_root>/ann_pdf/<year>/<announcement_id>.pdf
稳定 id(数据契约②配方):art_code/announcement_id 为源站稳定 id;爬虫层
跨天重复行会在数据里原样出现——核内零去重,消费方按 id 自行幂等。
"""
import os
import sanguo_data.partition_reader as pr
def get_news_meta(start=None, end=None, symbols=None):
"""新闻元数据(dt 分区直读)。symbols=裸 6 位代码列表,下推过滤 stock_code。"""
df = pr.read_partitions(pr.corpus_root(), "news_meta", start, end)
if symbols and not df.empty:
df = df[df["stock_code"].isin(set(symbols))]
return df
def get_ann_meta(start=None, end=None, symbols=None):
"""公告元数据。symbols 下推过滤 sec_code;ann_type/标题已透传(因子线
选择性抽取 PDF 用,不盲抽)。"""
df = pr.read_partitions(pr.corpus_root(), "ann_meta", start, end)
if symbols and not df.empty:
df = df[df["sec_code"].isin(set(symbols))]
return df
def get_news_fulltext(ids):
"""按 art_code 批量取全文(因子回访高信号条目/LLM 分块天然按 id)。"""
df = pr.read_partitions(pr.corpus_root(), "news_fulltext")
if ids and not df.empty:
df = df[df["art_code"].isin(set(ids))]
return df
def ann_pdf_dir(year):
"""公告 PDF 年目录(文件名=announcement_id.pdf,与 ann_meta 同名 join)。"""
return os.path.join(pr.corpus_root(), "ann_pdf", str(year))
def get_watermark():
"""镜像水位线(None=尚无成功推送记录)。"""
return pr.read_watermark(pr.corpus_root())
+39
View File
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""dmsk 财务五域薄封装(spec §20.11.3)。
schema 契约(2026-09-17 NAS 实测落档,§20.11.3 数据契约):
- 五域每行 NOTICE_DATE 必在(实测全命中;缺失=写路径事故,消费方按整期
弃用=宁缺毋假红线)
- 代码双列:SECUCODE 带后缀(301569.SZ)/ SECURITY_CODE 裸 6 位
- 金额单位=元(EM datacenter 原生,非万元)
- REPORT_DATE=分区键同值(dt=<REPORT_DATE>)
noticed_by=纯 filter 非 PIT merge(文档明示):只做 NOTICE_DATE<=noticed_by
的行级过滤(日期前缀比较);「static 基线为主+dmsk 新鲜度补丁」的 PIT 合并
归因子侧 fundamental_pit(§19.3 下游读法),不在数据线。
"""
import sanguo_data.partition_reader as pr
DOMAINS = ("balance", "cashflow", "express", "forecast", "income")
def get_domain(domain, start=None, end=None, noticed_by=None):
"""读五域之一(dt=REPORT_DATE 分区直读,零转换零去重)。
start/end 过滤的是 REPORT_DATE 分区值;noticed_by 过滤 NOTICE_DATE
(接受 'YYYY-MM-DD' 或全时间戳,按日期前缀比较)。
"""
if domain not in DOMAINS:
raise ValueError("domain 必须是 %s 之一, 收到 %r" % (DOMAINS, domain))
df = pr.read_partitions(pr.fund_dmsk_root(), domain, start, end)
if noticed_by is not None and not df.empty:
if "NOTICE_DATE" not in df.columns:
raise KeyError(
"域 %s 缺 NOTICE_DATE 列(数据契约①违约, 整期弃用勿消费)" % domain)
df = df[df["NOTICE_DATE"].astype(str).str[:10] <= noticed_by[:10]]
return df
def get_watermark():
"""镜像水位线(None=尚无成功推送记录)。"""
return pr.read_watermark(pr.fund_dmsk_root())
+105
View File
@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
"""通用 dt= 分区 parquet 读取核(spec §20.11.3,corpus/dmsk 消费接口基座)。
核内零语义(因子线 09-17 契约):
- 不做 as-of/PIT 合并——因子侧 fundamental_pit 是唯一正确性模型,两处实现会漂
且 PIT 错是静默错;dmsk 按 REPORT_DATE 分区,「as of D」本就无法靠分区剪枝。
- 零静默去重——幂等去重归写路径;镜像重放/分区重写出重复行原样透出,
取哪行=消费方语义决策。
失败分级(策略线 09-17 契约):
- 缺数据(根/域/分区不存在)→ 空 DataFrame,绝不 raise
(「数据失败≠交易信号」,消费方沿用上次快照);
- 数据在但形状错(跨分区列集/类型漂移)→ PartitionSchemaError fail loud
(静默 coerce 会埋前视/单位错,研究侧静默错最贵)。
"""
import glob
import json
import os
import pandas as pd
_IS_WIN = os.name == "nt"
DEFAULT_CORPUS_ROOT_WIN = r"C:\sanguo_vnpy_v2\data\corpus"
DEFAULT_CORPUS_ROOT_POSIX = "/volume1/stock/corpus"
DEFAULT_DMSK_ROOT_WIN = r"C:\sanguo_vnpy_v2\data\fundamentals\dmsk"
DEFAULT_DMSK_ROOT_POSIX = "/volume1/stock/fundamentals/dmsk"
WATERMARK_NAME = "_mirror_watermark.json"
_DT_PREFIX = "dt="
class PartitionSchemaError(RuntimeError):
"""跨分区 schema 漂移(列集/类型不一致)。"""
def corpus_root():
"""corpus 族根(§20.11.3:双机各读各的,env 覆盖同 SANGUO_DATA_ROOT 模式)。"""
return os.environ.get("SANGUO_CORPUS_ROOT") or (
DEFAULT_CORPUS_ROOT_WIN if _IS_WIN else DEFAULT_CORPUS_ROOT_POSIX)
def fund_dmsk_root():
"""dmsk 族根。"""
return os.environ.get("SANGUO_FUND_DMSK_ROOT") or (
DEFAULT_DMSK_ROOT_WIN if _IS_WIN else DEFAULT_DMSK_ROOT_POSIX)
def _partitions(domain_root):
if not os.path.isdir(domain_root):
return []
out = []
for name in sorted(os.listdir(domain_root)):
p = os.path.join(domain_root, name)
if os.path.isdir(p) and name.startswith(_DT_PREFIX):
out.append((name[len(_DT_PREFIX):], p))
return out
def list_dates(root, domain):
"""可用分区日期列表(dt= 前缀剥掉,升序)。缺根/缺域=空列表。"""
return [d for d, _ in _partitions(os.path.join(root, domain))]
def _schema_sig(df):
return tuple((str(c), str(df[c].dtype)) for c in df.columns)
def read_partitions(root, domain, start=None, end=None):
"""读域的 dt= 分区并拼接(含分区内全部 part-*.parquet)。
start/end 为 ISO 日期字符串,含端点、字符串比较。零去重零转换;
跨文件 schema 漂移 raise PartitionSchemaError;无数据返空 DataFrame。
"""
parts = _partitions(os.path.join(root, domain))
if start is not None:
parts = [(d, p) for d, p in parts if d >= start]
if end is not None:
parts = [(d, p) for d, p in parts if d <= end]
frames = []
sig = None
for _, pdir in parts:
for pf in sorted(glob.glob(os.path.join(pdir, "*.parquet"))):
df = pd.read_parquet(pf)
cur_sig = _schema_sig(df)
if sig is None:
sig = cur_sig
elif cur_sig != sig:
raise PartitionSchemaError(
"schema 漂移: %s 签名 %s != 首文件 %s (域=%s)" % (
pf, cur_sig, sig, domain))
frames.append(df)
if not frames:
return pd.DataFrame()
return pd.concat(frames, ignore_index=True)
def read_watermark(root):
"""镜像水位线(§20.11.2 推送链落在族根的 _mirror_watermark.json)。
返回 {generated_at, domains:{domain: 最大分区值}} 或 None(无文件)。
"""
p = os.path.join(root, WATERMARK_NAME)
if not os.path.exists(p):
return None
with open(p) as f:
return json.load(f)
+60
View File
@@ -0,0 +1,60 @@
# corpus_reader 薄封装测试(spec §20.11.3:symbols 下推/ids 批取/容错分级)
# 2026-09-17 TDD RED -> GREEN
import os
import pandas as pd
import pytest
import sanguo_data.corpus_reader as cr
@pytest.fixture
def tree(tmp_path, monkeypatch):
root = tmp_path / "corpus"
monkeypatch.setenv("SANGUO_CORPUS_ROOT", str(root))
(root / "news_meta" / "dt=2026-09-17").mkdir(parents=True)
(root / "news_fulltext" / "dt=2026-09-17").mkdir(parents=True)
(root / "ann_meta" / "dt=2026-09-17").mkdir(parents=True)
(root / "ann_pdf" / "2026").mkdir(parents=True)
pd.DataFrame({
"art_code": ["A1", "A2"], "stock_code": ["600519", "000001"],
"title": ["t1", "t2"],
}).to_parquet(root / "news_meta" / "dt=2026-09-17" / "part-0.parquet", index=False)
pd.DataFrame({
"art_code": ["A1", "A2"], "content_text": ["c1", "c2"],
}).to_parquet(root / "news_fulltext" / "dt=2026-09-17" / "part-0.parquet", index=False)
pd.DataFrame({
"announcement_id": ["1225567781"], "sec_code": ["605488"],
"title": ["理财公告"], "ann_type": ["01010503||011705"],
}).to_parquet(root / "ann_meta" / "dt=2026-09-17" / "part-0.parquet", index=False)
return root
class TestNews:
def test_get_news_meta_symbols_pushdown(self, tree):
df = cr.get_news_meta(symbols=["600519"])
assert list(df["art_code"]) == ["A1"]
def test_get_news_meta_no_symbols_all(self, tree):
assert len(cr.get_news_meta()) == 2
def test_get_news_fulltext_by_ids(self, tree):
df = cr.get_news_fulltext(["A2", "A1"])
assert set(df["art_code"]) == {"A1", "A2"}
def test_empty_root_returns_empty_no_raise(self, tmp_path, monkeypatch):
monkeypatch.setenv("SANGUO_CORPUS_ROOT", str(tmp_path / "empty"))
assert cr.get_news_meta().empty
assert cr.get_news_fulltext(["A1"]).empty
class TestAnn:
def test_get_ann_meta_symbols(self, tree):
df = cr.get_ann_meta(symbols=["605488"])
assert list(df["announcement_id"]) == ["1225567781"]
def test_get_ann_meta_miss_returns_empty(self, tree):
assert cr.get_ann_meta(symbols=["999999"]).empty
def test_ann_pdf_dir(self, tree):
assert cr.ann_pdf_dir(2026) == str(tree / "ann_pdf" / "2026")
@@ -0,0 +1,60 @@
# fund_dmsk_reader 薄封装测试(spec §20.11.3:noticed_by 纯过滤+NOTICE_DATE 契约 fail loud)
# 2026-09-17 TDD RED -> GREEN
import pandas as pd
import pytest
import sanguo_data.fund_dmsk_reader as dr
@pytest.fixture
def tree(tmp_path, monkeypatch):
root = tmp_path / "dmsk"
monkeypatch.setenv("SANGUO_FUND_DMSK_ROOT", str(root))
(root / "balance" / "dt=2026-06-30").mkdir(parents=True)
(root / "income" / "dt=2026-06-30").mkdir(parents=True)
pd.DataFrame({
"SECUCODE": ["301569.SZ", "000002.SZ"],
"SECURITY_CODE": ["301569", "000002"],
"NOTICE_DATE": ["2026-09-17 00:00:00", "2026-08-01 00:00:00"],
"REPORT_DATE": ["2026-06-30", "2026-06-30"],
"TOTAL_ASSETS": [2429015351.79, 1.0],
}).to_parquet(root / "balance" / "dt=2026-06-30" / "part-0.parquet", index=False)
pd.DataFrame({
"SECUCODE": ["301569.SZ"],
"SECURITY_CODE": ["301569"],
"NOTICE_DATE": ["2026-09-16 00:00:00"],
"REPORT_DATE": ["2026-06-30"],
}).to_parquet(root / "income" / "dt=2026-06-30" / "part-0.parquet", index=False)
return root
class TestGetDomain:
def test_returns_rows_with_all_domains_listed(self, tree):
assert dr.DOMAINS == ("balance", "cashflow", "express", "forecast", "income")
df = dr.get_domain("balance")
assert len(df) == 2
def test_bad_domain_raises(self, tree):
with pytest.raises(ValueError):
dr.get_domain("nope")
def test_noticed_by_pure_filter(self, tree):
df = dr.get_domain("balance", noticed_by="2026-09-01")
assert list(df["SECURITY_CODE"]) == ["000002"]
def test_noticed_by_accepts_full_timestamp(self, tree):
df = dr.get_domain("balance", noticed_by="2026-09-17 00:00:00")
assert len(df) == 2
def test_noticed_by_missing_column_fails_loud(self, tmp_path, monkeypatch):
root = tmp_path / "d2"
monkeypatch.setenv("SANGUO_FUND_DMSK_ROOT", str(root))
(root / "balance" / "dt=2026-06-30").mkdir(parents=True)
pd.DataFrame({"SECURITY_CODE": ["000001"]}).to_parquet(
root / "balance" / "dt=2026-06-30" / "p.parquet", index=False)
with pytest.raises(KeyError):
dr.get_domain("balance", noticed_by="2026-09-17")
def test_missing_root_empty_no_raise(self, tmp_path, monkeypatch):
monkeypatch.setenv("SANGUO_FUND_DMSK_ROOT", str(tmp_path / "absent"))
assert dr.get_domain("balance").empty
@@ -0,0 +1,107 @@
# partition_reader 通用分区读取核测试(spec §20.11.3:核内零语义+失败分级)
# 2026-09-17 TDD RED -> GREEN
import json
import os
import pandas as pd
import pytest
import sanguo_data.partition_reader as pr
@pytest.fixture
def tree(tmp_path):
root = tmp_path / "corpus"
(root / "news_meta" / "dt=2026-09-16").mkdir(parents=True)
(root / "news_meta" / "dt=2026-09-17").mkdir(parents=True)
(root / "news_meta" / "not-a-partition").mkdir(parents=True)
pd.DataFrame({"art_code": ["a1", "a2"], "stock_code": ["600519", "000001"]}) \
.to_parquet(root / "news_meta" / "dt=2026-09-16" / "part-0.parquet", index=False)
pd.DataFrame({"art_code": ["a3"], "stock_code": ["600519"]}) \
.to_parquet(root / "news_meta" / "dt=2026-09-17" / "part-0.parquet", index=False)
pd.DataFrame({"art_code": ["a4"], "stock_code": ["300750"]}) \
.to_parquet(root / "news_meta" / "dt=2026-09-17" / "part-1.parquet", index=False)
return str(root)
class TestRoots:
def test_env_override(self, monkeypatch, tmp_path):
monkeypatch.setenv("SANGUO_CORPUS_ROOT", str(tmp_path))
assert pr.corpus_root() == str(tmp_path)
def test_default_posix(self, monkeypatch):
monkeypatch.delenv("SANGUO_CORPUS_ROOT", raising=False)
monkeypatch.setattr(pr, "_IS_WIN", False)
assert pr.corpus_root() == "/volume1/stock/corpus"
def test_default_win(self, monkeypatch):
monkeypatch.delenv("SANGUO_CORPUS_ROOT", raising=False)
monkeypatch.setattr(pr, "_IS_WIN", True)
assert pr.corpus_root() == r"C:\sanguo_vnpy_v2\data\corpus"
class TestListDates:
def test_sorted_dt_values_ignores_non_partition(self, tree):
assert pr.list_dates(tree, "news_meta") == ["2026-09-16", "2026-09-17"]
def test_missing_domain_empty(self, tree):
assert pr.list_dates(tree, "nope") == []
class TestReadPartitions:
def test_concat_all_partitions_and_part_files(self, tree):
df = pr.read_partitions(tree, "news_meta")
assert list(df["art_code"]) == ["a1", "a2", "a3", "a4"]
def test_start_end_filter_inclusive(self, tree):
df = pr.read_partitions(tree, "news_meta", start="2026-09-17", end="2026-09-17")
assert list(df["art_code"]) == ["a3", "a4"]
df = pr.read_partitions(tree, "news_meta", start="2026-09-16", end="2026-09-16")
assert list(df["art_code"]) == ["a1", "a2"]
def test_duplicate_rows_survive_no_silent_dedup(self, tmp_path):
# 核内零去重(因子线契约):同键行跨分区重复必须原样透出
root = tmp_path / "d"
for dt in ("dt=2026-09-16", "dt=2026-09-17"):
(root / "x" / dt).mkdir(parents=True)
pd.DataFrame({"SECURITY_CODE": ["000001"], "REPORT_DATE": ["2026-06-30"]}) \
.to_parquet(root / "x" / dt / "part-0.parquet", index=False)
df = pr.read_partitions(str(root), "x")
assert len(df) == 2
def test_missing_root_empty_df(self, tmp_path):
df = pr.read_partitions(str(tmp_path / "absent"), "news_meta")
assert isinstance(df, pd.DataFrame) and df.empty
def test_empty_domain_dir_empty_df(self, tmp_path):
(tmp_path / "corpus" / "news_meta").mkdir(parents=True)
df = pr.read_partitions(str(tmp_path / "corpus"), "news_meta")
assert df.empty
def test_schema_column_drift_fails_loud(self, tmp_path):
root = tmp_path / "d"
(root / "x" / "dt=2026-09-16").mkdir(parents=True)
(root / "x" / "dt=2026-09-17").mkdir(parents=True)
pd.DataFrame({"a": [1], "b": [2]}).to_parquet(root / "x" / "dt=2026-09-16" / "p.parquet", index=False)
pd.DataFrame({"a": [1], "c": [3]}).to_parquet(root / "x" / "dt=2026-09-17" / "p.parquet", index=False)
with pytest.raises(pr.PartitionSchemaError):
pr.read_partitions(str(root), "x")
def test_schema_dtype_drift_fails_loud(self, tmp_path):
root = tmp_path / "d"
(root / "x" / "dt=2026-09-16").mkdir(parents=True)
(root / "x" / "dt=2026-09-17").mkdir(parents=True)
pd.DataFrame({"a": [1]}).to_parquet(root / "x" / "dt=2026-09-16" / "p.parquet", index=False)
pd.DataFrame({"a": [1.5]}).to_parquet(root / "x" / "dt=2026-09-17" / "p.parquet", index=False)
with pytest.raises(pr.PartitionSchemaError):
pr.read_partitions(str(root), "x")
class TestWatermark:
def test_read_watermark_dict(self, tree):
with open(os.path.join(tree, pr.WATERMARK_NAME), "w") as f:
json.dump({"generated_at": "2026-09-17T16:00:00", "domains": {"news_meta": "2026-09-17"}}, f)
assert pr.read_watermark(tree)["domains"]["news_meta"] == "2026-09-17"
def test_read_watermark_absent_none(self, tree):
assert pr.read_watermark(tree) is None