# -*- 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.fetchers.base import EVENT_PANEL_TYPES 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_mounted(self, provider): """hot_rank 2026-09-04 拍板挂载(墙测量5轮收官,19:30档)——白名单放行。 fixture 无 hot_rank 目录=合法缺失,返回空 df(与缺目录语义一致)。 """ assert "hot_rank" in EVENT_PANEL_TYPES df = provider.get_event_panel("hot_rank", date="2024-06-18") assert df.empty 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