Files
sanguo_vnpy_v2/tests/portfolio/test_event_panel_fetchers.py
T
claude_dev 4966d30a11
CI/CD / test (push) Failing after 3s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped
feat(provider): A档截面数据使用层出口——泛型底座+涨停池门面双层接口
09-02任务①落地。§15采集的14类panel数据此前零读取出口,策略/因子session
想用只能手搓parquet路径;经业界对照(dsa get_limit_up_pool/tushare
limit_list_d U/D/Z参数化/聚宽per-domain/OpenBB TET)定稿「泛型底座+域
语义门面」:

①get_event_panel(event_type,date/start+end,trading_days_only=True):
保真底座,白名单14类统一通道(=采集注册表口径,hot_rank未挂载不入列),
返回akshare原样中文列+trade_date(文件名合成);缺文件=合法缺失→空df;
trading_days_only默认用get_trade_days滤非交易日——治快照族「周中法定
假日文件=上一交易日态复制品」的区间双计数(采集层只按周一~五落盘,
周六根本无文件,风险面=周中假日);=False按周一~五枚举(=采集口径)

②get_limit_pool(kind=zt|zbgc|dtgc,...):涨停池门面(温度计/炸板率/行业
集中度消费契约),kind三合一←tushare limit_list_d;英文标准列
code/consecutive_boards/seal_amount/break_count/industry…(键名对齐dsa);
映射表基于VPS真实parquet实测(dtgc实测含动态市盈率/封单资金/板上成交额/
连续跌停/开板次数,与文档口径有差);akshare改中文列名时本层吸收漂移,
有行缺源列→DataSchemaError fail-fast;真空日(dtgc 0跌停空文件无列)
→仍返标准列空表schema稳定

③实现=TET Fetcher(fetchers/event_panel.py双Fetcher同文件,price.py先例),
FETCHERS注册表+event_panel/limit_pool两键(未来MCP出口零成本);base.py
新增_DateRangeParams跨字段校验(date/start+end互斥二选一);LocalUnified
Provider(回测)与SanguoMiniQmtProvider(实盘,委托self._unified)双侧同款
——方法面钉死测试强制无缺口,双侧同schema支持副本对照;miniQMT/QMT/
PTrade均无此类接口(业界惯例=外部源补),实盘同读本地文件

测试:21新测试(白名单fail-fast/日期语义/保真读/假日滤/门面schema/
列漂移fail-fast/真空日空表/注册表)+全量793绿;活文档§16+§15指引行 [vps]
2026-09-02 10:43:11 +08:00

269 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""事件/快照 panel 数据 Fetcher 测试(2026-09-02 A 档使用层出口)。
两层接口(设计=泛型底座 + 涨停池语义门面,对齐 OpenBB TET/tushare limit_list_d):
1. ``EventPanelFetcher``(get_event_panel): 保真通道——akshare 原样中文列
+ ``trade_date``(文件名合成);新数据类型零成本接入;缺文件=合法空。
2. ``LimitPoolFetcher``(get_limit_pool): 涨停池门面——kind 三合一
(zt/zbgc/dtgc ← tushare limit_list_d 的 U/D/Z 参数化),英文标准列
(键名对齐 dsa get_limit_up_pool),列缺失 fail-fast(吸收 akshare 列漂移)。
关键契约:
- ``trading_days_only=True``(默认)用 get_trade_days 滤非交易日——治快照族
「节假日文件=上一交易日态复制品」的区间双计数(§15 语义)。
- 列名映射基于 VPS 真实 parquet 实测(2026-09-02),非 akshare 文档臆测:
dtgc 实测含 动态市盈率/封单资金/板上成交额/连续跌停/开板次数。
- 真空日(如 dtgc 0 跌停)空文件无列:门面仍返标准列空表(schema 稳定)。
"""
from __future__ import annotations
import sqlite3
import pandas as pd
import pytest
from pydantic import ValidationError
from sanguo_portfolio.providers.fetchers import (
FETCHERS,
DataSchemaError,
EventPanelFetcher,
LimitPoolFetcher,
)
from sanguo_portfolio.providers.local_unified_provider import LocalUnifiedProvider
# 真实列名(VPS 20260819_zt_pool_dtgc.parquet 实测)
ZT_COLS = {
"序号", "代码", "名称", "涨跌幅", "最新价", "成交额", "流通市值", "总市值",
"换手率", "封板资金", "首次封板时间", "最后封板时间", "炸板次数",
"涨停统计", "连板数", "所属行业",
}
ZBGC_COLS = {
"序号", "代码", "名称", "涨跌幅", "最新价", "涨停价", "成交额", "流通市值",
"总市值", "换手率", "涨速", "首次封板时间", "炸板次数", "涨停统计",
"振幅", "所属行业",
}
DTGC_COLS = {
"序号", "代码", "名称", "涨跌幅", "最新价", "成交额", "流通市值", "总市值",
"动态市盈率", "换手率", "封单资金", "最后封板时间", "板上成交额",
"连续跌停", "开板次数", "所属行业",
}
# 门面标准键(每 kind 的完整 schema;trade_date 由读取层追加)
ZT_KEYS = {
"code", "name", "change_pct", "price", "amount", "float_market_cap",
"total_market_cap", "turnover_rate", "seal_amount", "first_seal_time",
"last_seal_time", "break_count", "limit_stat", "consecutive_boards",
"industry", "trade_date",
}
ZBGC_KEYS = ZT_KEYS - {"seal_amount", "last_seal_time", "consecutive_boards"} | {
"limit_price", "surge_speed", "amplitude"}
DTGC_KEYS = ZT_KEYS - {"first_seal_time", "break_count",
"consecutive_boards", "limit_stat"} | {
"pe_ttm", "board_amount", "consecutive_limit_downs", "open_count"}
def _mk_zt_row(code: str, boards: int, industry: str = "白酒") -> dict:
return {
"序号": 1, "代码": code, "名称": "样本股", "涨跌幅": 10.0, "最新价": 11.0,
"成交额": 1e8, "流通市值": 5e9, "总市值": 8e9, "换手率": 5.0,
"封板资金": 2e8, "首次封板时间": "092500", "最后封板时间": "150000",
"炸板次数": 0, "涨停统计": "2天/2板", "连板数": boards, "所属行业": industry,
}
@pytest.fixture
def provider(tmp_path):
"""交易日历(2024-06-18/20,19=周三非交易日) + static 各型样本文件。
2024-06-19=周中「法定假日」形态: 采集层按周一~五落盘会为它写
「上一交易日态复制品」(快照族语义)——用它钉死 trading_days_only 过滤。
"""
db = tmp_path / "quant_trading.db"
c = sqlite3.connect(str(db))
c.execute(
"CREATE TABLE dbbardata(symbol TEXT, exchange TEXT, datetime TEXT, "
"interval TEXT, volume REAL, turnover REAL, open_interest REAL, "
"open_price REAL, high_price REAL, low_price REAL, close_price REAL)"
)
for d in ("2024-06-18", "2024-06-20"):
c.execute(
"INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)",
("600519", "SSE", f"{d} 00:00:00", "d", 1000, 1e6, 0,
1.0, 1.0, 1.0, 1.0))
c.commit()
c.close()
static = tmp_path / "static"
def _write(t: str, day: str, rows: list) -> None:
d = static / t
d.mkdir(parents=True, exist_ok=True)
pd.DataFrame(rows, columns=list(rows[0]) if rows else None).to_parquet(
d / f"{day}_{t}.parquet", index=False)
_write("zt_pool", "20240618", [_mk_zt_row("600519", 2), _mk_zt_row("000001", 3)])
_write("zt_pool", "20240619", [_mk_zt_row("600519", 1)]) # 假日复制品
_write("zt_pool_zbgc", "20240619", [{
"序号": 1, "代码": "300999", "名称": "炸板股", "涨跌幅": 9.5, "最新价": 8.8,
"涨停价": 9.9, "成交额": 5e7, "流通市值": 2e9, "总市值": 3e9, "换手率": 12.0,
"涨速": 3.2, "首次封板时间": "093000", "炸板次数": 2, "涨停统计": "1天/0板",
"振幅": 8.0, "所属行业": "半导体"}])
_write("zt_pool_dtgc", "20240618", []) # 真空日(0 跌停)=空文件无列
_write("gdhs", "20240331", [
{"代码": "600519", "股东户数": 80000, "区间增减": -0.05},
{"代码": "000001", "股东户数": 500000, "区间增减": 0.02}])
return LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
# ======================== QueryParams strict(fail-fast) ========================
class TestEventPanelQueryParams:
def test_unknown_type_rejected(self, provider):
with pytest.raises(ValidationError, match="no_such_type"):
provider.get_event_panel("no_such_type", date="2024-06-18")
def test_hot_rank_not_mounted(self, provider):
"""hot_rank 墙测量未拍板挂载——白名单拒绝(与 PANEL_TYPES 口径一致)。"""
with pytest.raises(ValidationError, match="hot_rank"):
provider.get_event_panel("hot_rank", date="2024-06-18")
def test_date_and_start_exclusive(self, provider):
with pytest.raises(ValidationError, match="互斥"):
provider.get_event_panel(
"zt_pool", date="2024-06-18", start="2024-06-18",
end="2024-06-20")
def test_neither_date_nor_start(self, provider):
with pytest.raises(ValidationError, match="其一"):
provider.get_event_panel("zt_pool")
def test_start_requires_end(self, provider):
with pytest.raises(ValidationError, match="start 和 end"):
provider.get_event_panel("zt_pool", start="2024-06-18")
def test_bad_date_format(self, provider):
with pytest.raises(ValidationError):
provider.get_event_panel("zt_pool", date="20240618")
class TestLimitPoolQueryParams:
def test_unknown_kind_rejected(self, provider):
with pytest.raises(ValidationError, match="kind"):
provider.get_limit_pool(kind="ztbad", date="2024-06-18")
def test_date_semantics_shared(self, provider):
with pytest.raises(ValidationError, match="其一"):
provider.get_limit_pool(kind="zt")
# ======================== 泛型底座(保真通道) ========================
class TestEventPanelFetch:
def test_single_date_raw_columns_plus_trade_date(self, provider):
df = provider.get_event_panel("zt_pool", date="2024-06-18")
assert len(df) == 2
assert set(df.columns) >= ZT_COLS
assert "trade_date" in df.columns
assert set(df["trade_date"]) == {"2024-06-18"}
def test_range_trading_days_only_drops_holiday_file(self, provider):
"""默认过滤: 周中假日的复制品文件必须被滤掉(防区间双计数)。"""
df = provider.get_event_panel(
"zt_pool", start="2024-06-18", end="2024-06-20")
# 18(2行);19=非交易日复制品被滤;20 无文件(合法洞)
assert len(df) == 2
assert set(df["trade_date"]) == {"2024-06-18"}
def test_range_raw_mode_includes_holiday(self, provider):
df = provider.get_event_panel(
"zt_pool", start="2024-06-18", end="2024-06-20",
trading_days_only=False)
assert len(df) == 3
assert "2024-06-19" in set(df["trade_date"])
def test_missing_dir_returns_empty(self, provider):
"""从未采集的类型(xueqiu_hot 目录不存在)=合法缺失,空 df 不报错。"""
df = provider.get_event_panel("xueqiu_hot", date="2024-06-18")
assert isinstance(df, pd.DataFrame) and df.empty
def test_empty_file_day_contributes_nothing(self, provider):
df = provider.get_event_panel(
"zt_pool_dtgc", start="2024-06-18", end="2024-06-20")
assert df.empty # 18=真空文件,19/20 无文件
def test_gdhs_period_read(self, provider):
"""gdhs per-period: unit={季度末}_gdhs,同一底座直接可读。"""
df = provider.get_event_panel("gdhs", date="2024-03-31")
assert len(df) == 2
assert set(df["trade_date"]) == {"2024-03-31"}
# ======================== 涨停池门面(标准模型) ========================
class TestLimitPoolFetch:
def test_zt_english_schema(self, provider):
df = provider.get_limit_pool(kind="zt", date="2024-06-18")
assert set(df.columns) == ZT_KEYS
row = df.iloc[0]
assert row["consecutive_boards"] == 2
assert row["seal_amount"] == 2e8
assert row["break_count"] == 0
assert row["industry"] == "白酒"
assert row["limit_stat"] == "2天/2板"
def test_zbgc_schema(self, provider):
df = provider.get_limit_pool(kind="zbgc", date="2024-06-19")
assert set(df.columns) == ZBGC_KEYS
assert df.iloc[0]["break_count"] == 2
assert df.iloc[0]["surge_speed"] == 3.2
def test_dtgc_empty_day_keeps_standard_schema(self, provider):
"""真空日(0 跌停)空文件无列——门面仍返标准列空表(schema 稳定)。"""
df = provider.get_limit_pool(kind="dtgc", date="2024-06-18")
assert df.empty
assert set(df.columns) == DTGC_KEYS
def test_dtgc_real_columns_mapped(self, provider, tmp_path):
"""dtgc 真实列(实测: 封单资金/连续跌停/开板次数...)→ 标准键。"""
f = tmp_path / "static" / "zt_pool_dtgc" / "20240620_zt_pool_dtgc.parquet"
pd.DataFrame([{
"序号": 1, "代码": "600999", "名称": "跌停股", "涨跌幅": -10.0,
"最新价": 5.0, "成交额": 3e7, "流通市值": 1e9, "总市值": 2e9,
"动态市盈率": 15.0, "换手率": 8.0, "封单资金": 6e7,
"最后封板时间": "145950", "板上成交额": 1e6, "连续跌停": 2,
"开板次数": 1, "所属行业": "地产"}]).to_parquet(f, index=False)
df = provider.get_limit_pool(kind="dtgc", date="2024-06-20")
assert len(df) == 1
assert set(df.columns) == DTGC_KEYS
row = df.iloc[0]
assert row["seal_amount"] == 6e7
assert row["consecutive_limit_downs"] == 2
assert row["open_count"] == 1
assert row["pe_ttm"] == 15.0
def test_missing_code_column_fail_fast(self, provider, tmp_path):
"""脏数据: 有行但缺 代码 → DataSchemaError(门面吸收列漂移的点)。"""
f = tmp_path / "static" / "zt_pool" / "20240620_zt_pool.parquet"
pd.DataFrame([{"名称": "无名股", "连板数": 1}]).to_parquet(f, index=False)
with pytest.raises(DataSchemaError, match="代码"):
provider.get_limit_pool(kind="zt", date="2024-06-20")
def test_range_trading_filter_applies(self, provider):
df = provider.get_limit_pool(kind="zt", start="2024-06-18",
end="2024-06-20")
assert len(df) == 2
assert "2024-06-19" not in set(df["trade_date"])
# ======================== 注册表 ========================
class TestRegistry:
def test_fetchers_registered(self):
assert FETCHERS["event_panel"] is EventPanelFetcher
assert FETCHERS["limit_pool"] is LimitPoolFetcher