"""TET Fetcher(窄试点B)测试: 等价性 + strict fail-fast + Optional 契约。 三组覆盖(方案 2026-08-15 用户定案): 1. **TestEquivalence**: 合法参数下 `_ex` vs 老接口输出逐值一致(extract SQL 原样搬迁的回归保障;Phase 2 真策略回测对照前的第一道防线)。 2. **TestStrictFailFast**: 非法参数(老接口静默返空/取默认)→ ValueError; 脏数据(核心列全空)→ DataSchemaError。这是 _ex 的有意新契约。 3. **TestOptionalContract**: high_limit/low_limit/paused 缺失补默认=显式契约 (非兜底;补 NaN 会被 bullet_trade bool(NaN)=True 当停牌全撤单)。 fixture 模式照 test_local_unified_provider.py(tmp_path sqlite 自建表,零网络)。 """ from __future__ import annotations import sqlite3 import pandas as pd import pytest from pydantic import ValidationError from sanguo_portfolio.providers.fetchers import DataSchemaError from sanguo_portfolio.providers.local_unified_provider import LocalUnifiedProvider @pytest.fixture def unified_provider(tmp_path): """dbbardata(含脏数据股) + bs_adjust_factor + constituent_unified。""" 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)" ) rows = [ # 600519: 除权日 2024-06-19 close 1000→900 跳水(qfq 用) ("600519", "SSE", "2024-06-18 00:00:00", "d", 1000, 1e6, 0, 1000.0, 1010.0, 990.0, 1000.0), ("600519", "SSE", "2024-06-19 00:00:00", "d", 1000, 1e6, 0, 900.0, 910.0, 890.0, 900.0), ("600519", "SSE", "2024-06-20 00:00:00", "d", 1000, 1e6, 0, 910.0, 920.0, 900.0, 910.0), # 000001 深市 2 天(panel 多标的/宽表用) ("000001", "SZSE", "2024-06-18 00:00:00", "d", 2e6, 2e7, 0, 10.0, 10.5, 9.8, 10.2), ("000001", "SZSE", "2024-06-19 00:00:00", "d", 2e6, 2e7, 0, 10.3, 10.6, 10.1, 10.5), # 600999 脏数据: 有行但 close 全 NULL(strict 校验靶) ("600999", "SSE", "2024-06-18 00:00:00", "d", 100, 1e3, 0, None, None, None, None), ] c.executemany("INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows) c.execute( "CREATE TABLE bs_adjust_factor(code TEXT, dividOperateDate TEXT, " "foreAdjustFactor REAL, backAdjustFactor REAL, adjustFactor REAL)" ) c.execute( "INSERT INTO bs_adjust_factor VALUES('sh.600519','2024-06-19',0.9,0,0)" ) c.execute( "CREATE TABLE constituent_unified(index_code TEXT, code TEXT, " "in_current INTEGER, was_removed INTEGER, code_name TEXT)" ) c.executemany( "INSERT INTO constituent_unified VALUES(?,?,?,?,?)", [ ("000300", "600519", 1, 0, "贵州茅台"), ("000300", "000001", 0, 1, "平安银行"), # 被踢(治偏差) ("000300", "bad-code", 1, 0, "非法码"), # transform 清洗靶 ], ) c.commit() c.close() # data_dir 指向空目录: fundamentals 无 static parquet → 全 NaN 行(两接口同路径) (tmp_path / "static").mkdir(exist_ok=True) return LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) # ======================== 1. 等价性: _ex vs 老接口 ======================== class TestEquivalence: """合法参数下逐值一致(assert_frame_equal 默认容差 rtol=1e-5)。""" def test_get_price_raw(self, unified_provider): old = unified_provider.get_price( "600519.XSHG", start_date="2024-06-18", end_date="2024-06-20") new = unified_provider.get_price_ex( "600519.XSHG", start_date="2024-06-18", end_date="2024-06-20") pd.testing.assert_frame_equal(old, new) def test_get_price_qfq(self, unified_provider): old = unified_provider.get_price( "600519.XSHG", start_date="2024-06-18", end_date="2024-06-20", fq="qfq") new = unified_provider.get_price_ex( "600519.XSHG", start_date="2024-06-18", end_date="2024-06-20", fq="qfq") pd.testing.assert_frame_equal(old, new) def test_get_price_count_and_fields(self, unified_provider): kwargs = dict(security="600519.XSHG", count=2, fields=["close", "high_limit", "paused"], panel=False) old = unified_provider.get_price(**kwargs) new = unified_provider.get_price_ex(**kwargs) pd.testing.assert_frame_equal(old, new) def test_get_price_multi_panel(self, unified_provider): secs = ["600519.XSHG", "000001.XSHE"] old = unified_provider.get_price( secs, start_date="2024-06-18", end_date="2024-06-20", panel=False) new = unified_provider.get_price_ex( secs, start_date="2024-06-18", end_date="2024-06-20", panel=False) pd.testing.assert_frame_equal(old, new) def test_get_price_missing_symbol_empty_both(self, unified_provider): # 库中无数据的标的: 两接口都返空 DataFrame(空=合法缺失) old = unified_provider.get_price("300999.XSHE", start_date="2024-06-18") new = unified_provider.get_price_ex("300999.XSHE", start_date="2024-06-18") assert old.empty and new.empty def test_get_closes_panel_raw_and_missing_col(self, unified_provider): syms = ["600519.XSHG", "000001.XSHE", "300999.XSHE"] # 含缺失标的 old = unified_provider.get_closes_panel( syms, start="2024-06-18", end="2024-06-20") new = unified_provider.get_closes_panel_ex( syms, start="2024-06-18", end="2024-06-20") pd.testing.assert_frame_equal(old, new) assert new["300999.XSHE"].isna().all() # 缺失标的补全 NaN 列(契约一致) def test_get_closes_panel_qfq(self, unified_provider): old = unified_provider.get_closes_panel( ["600519.XSHG"], start="2024-06-18", end="2024-06-20", fq="qfq") new = unified_provider.get_closes_panel_ex( ["600519.XSHG"], start="2024-06-18", end="2024-06-20", fq="qfq") pd.testing.assert_frame_equal(old, new) def test_get_constituent(self, unified_provider): old = unified_provider.get_constituent("000300.XSHG") new = unified_provider.get_constituent_ex("000300.XSHG") assert old == new assert "600519.XSHG" in new and "000001.XSHE" in new # 并集(含被踢) assert not any("bad" in s for s in new) # 非法码清洗一致 def test_get_fundamentals_df(self, unified_provider): # 空 static 目录: 两接口同路径返全 NaN 行,验证组装/index/fields 过滤一致 old = unified_provider.get_fundamentals_df(["600519.XSHG"], date="2024-06-20") new = unified_provider.get_fundamentals_df_ex(["600519.XSHG"], date="2024-06-20") pd.testing.assert_frame_equal(old, new) old2 = unified_provider.get_fundamentals_df( ["600519.XSHG", "000001.XSHE"], date="2024-06-20", fields=["market_cap"]) new2 = unified_provider.get_fundamentals_df_ex( ["600519.XSHG", "000001.XSHE"], date="2024-06-20", fields=["market_cap"]) pd.testing.assert_frame_equal(old2, new2) # ======================== 2. strict fail-fast ======================== class TestStrictFailFast: """非法参数/脏数据直接报错(_ex 有意新契约;老接口静默)。""" def test_price_bad_frequency(self, unified_provider): # 老接口对 '1m' 静默返空 DataFrame;_ex 报错(15m 应走 panel_ex) with pytest.raises(ValidationError, match="frequency"): unified_provider.get_price_ex("600519.XSHG", frequency="1m") def test_price_bad_fq(self, unified_provider): with pytest.raises(ValidationError, match="fq"): unified_provider.get_price_ex("600519.XSHG", fq="xx") def test_price_bad_count(self, unified_provider): with pytest.raises(ValidationError, match="count"): unified_provider.get_price_ex("600519.XSHG", count=0) def test_price_bad_date(self, unified_provider): with pytest.raises(ValidationError, match="日期"): unified_provider.get_price_ex("600519.XSHG", start_date="2024/06/18") def test_price_empty_security(self, unified_provider): with pytest.raises(ValidationError, match="security"): unified_provider.get_price_ex([]) def test_price_unknown_kwarg_forbidden(self, unified_provider): # 拼错参数名直接报错(_ex 显式签名 → TypeError;直调 Fetcher → pydantic # extra=forbid ValidationError;老接口 **kwargs 静默吞) with pytest.raises((ValidationError, TypeError)): unified_provider.get_price_ex("600519.XSHG", start_dat="2024-06-18") def test_price_dirty_data_close_all_null(self, unified_provider): # 600999 有行但 close 全 NULL: 老接口静默流 NaN,_ex fail-fast with pytest.raises(DataSchemaError, match="close_price"): unified_provider.get_price_ex( "600999.XSHG", start_date="2024-06-18", end_date="2024-06-20") def test_panel_empty_symbols(self, unified_provider): with pytest.raises(ValidationError, match="symbols"): unified_provider.get_closes_panel_ex([], start="2024-06-18", end="2024-06-20") def test_panel_bad_date(self, unified_provider): with pytest.raises(ValidationError, match="日期"): unified_provider.get_closes_panel_ex( ["600519.XSHG"], start="20240618", end="2024-06-20") def test_panel_bad_interval(self, unified_provider): with pytest.raises(ValidationError, match="interval"): unified_provider.get_closes_panel_ex( ["600519.XSHG"], start="2024-06-18", end="2024-06-20", interval="d; DROP TABLE") def test_panel_dirty_data(self, unified_provider): with pytest.raises(DataSchemaError): unified_provider.get_closes_panel_ex( ["600999.XSHG"], start="2024-06-18", end="2024-06-20") def test_constituent_empty_index(self, unified_provider): with pytest.raises(ValidationError, match="index"): unified_provider.get_constituent_ex("") def test_fundamentals_empty_stocks(self, unified_provider): with pytest.raises(ValidationError, match="stocks"): unified_provider.get_fundamentals_df_ex([]) # ======================== 3. Optional 契约(非兜底) ======================== class TestOptionalContract: """high_limit/low_limit/paused 缺失补默认=2026-08-15 用户定案的显式契约。""" def test_high_limit_estimated_not_nan(self, unified_provider): df = unified_provider.get_price_ex( "600519.XSHG", start_date="2024-06-18", end_date="2024-06-18", fields=["close", "high_limit", "low_limit"], ) assert abs(df["high_limit"].iloc[0] - round(1000.0 * 1.1, 2)) < 1e-6 assert abs(df["low_limit"].iloc[0] - round(1000.0 * 0.9, 2)) < 1e-6 def test_paused_false_not_nan(self, unified_provider): df = unified_provider.get_price_ex( "600519.XSHG", start_date="2024-06-18", end_date="2024-06-18", fields=["close", "paused"], ) # bool(NaN)=True 会被当停牌;契约=显式 False assert not df["paused"].iloc[0] def test_unknown_field_nan(self, unified_provider): # 未知可选列(如聚宽特有字段)缺失 → NaN(声明行为,策略自查) df = unified_provider.get_price_ex( "600519.XSHG", start_date="2024-06-18", end_date="2024-06-18", fields=["close", "acc_net_value"], ) assert df["acc_net_value"].isna().all() # ======================== SARGable 日期区间(2026-08-25 P0) ======================== class TestSargableDateRange: """substr(datetime,1,10) 对索引列套函数 → 日期区间打不进复合索引 datetime 列, 每股扫全量日线史取短窗(momentum RPS 池 3226 只 30 天窗 VPS 实测 174s;provider /datareader 四热路径同病)。裸列 ``datetime>=start AND datetime