"""LocalUnifiedProvider 单元测试(spec §6 使用层)。 Mac 本地 TDD: sqlite tmp_path + tmp parquet fixture,零 VPS 依赖,零网络。 覆盖: - ``jq_to_dbbardata`` / ``dbbardata_to_jq`` / ``_jq_to_bs_code`` 代码转换 - ``_build_qfq_factor`` 复权因子构造(asof) - ``get_price`` dbbardata('d') raw + fq='qfq' 前复权 + panel=False 长表 - ``get_index_stocks`` constituent_unified 并集(治偏差,无 date 时点) - ``get_fundamentals_df`` pe/pb/ps/pcf←valuation_baostock + 市值←static akshare + 三表委托 LocalParquetProvider - 辅助方法 get_trade_days / get_all_securities / get_security_info / get_current_tick / get_split_dividend """ from __future__ import annotations import sqlite3 from typing import Any, Dict, List import pandas as pd import pytest from sanguo_portfolio.providers.local_unified_provider import ( jq_to_dbbardata, dbbardata_to_jq, _jq_to_bs_code, _build_qfq_factor, LocalUnifiedProvider, ) # ======================== Task 0: 代码转换 ======================== class TestCodeFormat: def test_jq_to_dbbardata_sh(self): assert jq_to_dbbardata("600519.XSHG") == ("600519", "SSE") def test_jq_to_dbbardata_sz(self): assert jq_to_dbbardata("000001.XSHE") == ("000001", "SZSE") def test_jq_to_dbbardata_pure_digit_sh(self): # 6 开头 → SSE assert jq_to_dbbardata("600519") == ("600519", "SSE") def test_jq_to_dbbardata_pure_digit_sz(self): # 0/3 开头 → SZSE assert jq_to_dbbardata("000001") == ("000001", "SZSE") assert jq_to_dbbardata("300001") == ("300001", "SZSE") def test_dbbardata_to_jq_sh(self): assert dbbardata_to_jq("600519", "SSE") == "600519.XSHG" def test_dbbardata_to_jq_sz(self): assert dbbardata_to_jq("000001", "SZSE") == "000001.XSHE" def test_round_trip_jq_to_dbbardata_to_jq(self): original = "600519.XSHG" sym, exc = jq_to_dbbardata(original) assert dbbardata_to_jq(sym, exc) == original def test_jq_to_bs_code_sh(self): # 600519.XSHG → 'sh.600519'(bs_adjust_factor.code 格式) assert _jq_to_bs_code("600519.XSHG") == "sh.600519" def test_jq_to_bs_code_sz(self): assert _jq_to_bs_code("000001.XSHE") == "sz.000001" # ======================== Task 0: 复权因子 asof ======================== class TestBuildQfqFactor: def test_asof_before_all_events_uses_earliest(self, tmp_path): # 2 除权事件, 最新=1.0 db = tmp_path / "t.db" c = sqlite3.connect(str(db)) c.execute( "CREATE TABLE bs_adjust_factor(code TEXT, dividOperateDate TEXT, " "foreAdjustFactor REAL, backAdjustFactor REAL, adjustFactor REAL)" ) c.executemany( "INSERT INTO bs_adjust_factor VALUES(?,?,?,?,?)", [ ("sh.600519", "2024-06-19", 0.90, 0, 0), ("sh.600519", "2025-06-19", 1.00, 0, 0), ], ) c.commit() c.close() # 2023 早于所有事件 → 用最早 factor=0.90 dates = pd.to_datetime(["2023-01-01"]) f = _build_qfq_factor("sh.600519", sqlite3.connect(str(db)), dates) assert abs(f.iloc[0] - 0.90) < 1e-6 def test_asof_between_events(self, tmp_path): db = tmp_path / "t.db" c = sqlite3.connect(str(db)) c.execute( "CREATE TABLE bs_adjust_factor(code TEXT, dividOperateDate TEXT, " "foreAdjustFactor REAL, backAdjustFactor REAL, adjustFactor REAL)" ) c.executemany( "INSERT INTO bs_adjust_factor VALUES(?,?,?,?,?)", [ ("sh.600519", "2024-06-19", 0.90, 0, 0), ("sh.600519", "2025-06-19", 1.00, 0, 0), ], ) c.commit() c.close() # 2024-07 在两事件之间 → ≤ 的最大事件是 2024-06-19, factor=0.90 dates = pd.to_datetime(["2024-07-01"]) f = _build_qfq_factor("sh.600519", sqlite3.connect(str(db)), dates) assert abs(f.iloc[0] - 0.90) < 1e-6 def test_asof_after_all_events_uses_latest(self, tmp_path): db = tmp_path / "t.db" c = sqlite3.connect(str(db)) c.execute( "CREATE TABLE bs_adjust_factor(code TEXT, dividOperateDate TEXT, " "foreAdjustFactor REAL, backAdjustFactor REAL, adjustFactor REAL)" ) c.executemany( "INSERT INTO bs_adjust_factor VALUES(?,?,?,?,?)", [ ("sh.600519", "2024-06-19", 0.90, 0, 0), ("sh.600519", "2025-06-19", 1.00, 0, 0), ], ) c.commit() c.close() # 2025-07 晚于所有事件 → 最新 factor=1.00 dates = pd.to_datetime(["2025-07-01"]) f = _build_qfq_factor("sh.600519", sqlite3.connect(str(db)), dates) assert abs(f.iloc[0] - 1.00) < 1e-6 def test_no_events_returns_ones(self, tmp_path): db = tmp_path / "t.db" c = sqlite3.connect(str(db)) c.execute( "CREATE TABLE bs_adjust_factor(code TEXT, dividOperateDate TEXT, " "foreAdjustFactor REAL, backAdjustFactor REAL, adjustFactor REAL)" ) c.commit() c.close() # 无事件 → 全 1.0 dates = pd.to_datetime(["2024-01-01", "2024-06-01"]) f = _build_qfq_factor("sh.600519", sqlite3.connect(str(db)), dates) assert len(f) == 2 assert abs(f.iloc[0] - 1.0) < 1e-6 assert abs(f.iloc[1] - 1.0) < 1e-6 # ======================== Task 1: get_price fixture ======================== @pytest.fixture def unified_provider(tmp_path): """造小样本 sqlite fixture: dbbardata 日线 + bs_adjust_factor。""" 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)" ) # 600519: 除权日 2024-06-19 raw close 1000 → 900 跳水 rows = [ ("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), ] c.executemany("INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows) # 复权因子: 2024-06-19 起除权, factor=0.9 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.commit() c.close() return LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) # ======================== Task 1: get_price daily ======================== class TestGetPrice: def test_raw_keeps_original_prices(self, unified_provider): # raw: 除权日 close=900 跳水(原值) df = unified_provider.get_price( "600519.XSHG", start_date="2024-06-18", end_date="2024-06-20", fq="raw", ) assert len(df) == 3 assert abs(df.loc["2024-06-19", "close"] - 900.0) < 1e-6 def test_qfq_earlier_date_uses_earliest_factor(self, unified_provider): # 2024-06-18 早于除权日 06-19 → factor=0.9 → 1000*0.9=900 df = unified_provider.get_price( "600519.XSHG", start_date="2024-06-18", end_date="2024-06-20", fq="qfq", ) assert abs(df.loc["2024-06-18", "close"] - 900.0) < 1e-6 def test_qfq_after_event_uses_event_factor(self, unified_provider): # 2024-06-19/20 ≥ 除权日 → factor=0.9 → 900*0.9=810, 910*0.9=819 df = unified_provider.get_price( "600519.XSHG", start_date="2024-06-19", end_date="2024-06-20", fq="qfq", ) assert abs(df.loc["2024-06-19", "close"] - 810.0) < 1e-6 assert abs(df.loc["2024-06-20", "close"] - 819.0) < 1e-6 def test_panel_false_returns_long_table(self, unified_provider): # panel=False → 长表含 time + code 列 df = unified_provider.get_price( "600519.XSHG", end_date="2024-06-20", count=2, panel=False, fields=["close"], ) assert "code" in df.columns assert "time" in df.columns assert len(df) == 2 assert "600519.XSHG" in set(df["code"]) def test_fields_with_missing_column_fills_fallback(self, unified_provider): # high_limit 不在 dbbardata → 按 close×1.1 兜底估算(round 2, 与 get_current_tick 同口径) # 注:不填 NaN — 填 NaN 会被 bullet_trade 误判停牌导致订单全 cancel(unified-provider-paused-nan-bug) df = unified_provider.get_price( "600519.XSHG", end_date="2024-06-20", count=1, panel=False, fields=["close", "high_limit"], ) assert "high_limit" in df.columns expected = round(df.iloc[0]["close"] * 1.1, 2) assert abs(df.iloc[0]["high_limit"] - expected) < 1e-6 def test_minute_frequency_returns_empty(self, unified_provider): # 1m 频率无数据 → 返空 DataFrame df = unified_provider.get_price( "600519.XSHG", end_date="2024-06-20", frequency="1m", count=1, panel=False, ) assert isinstance(df, pd.DataFrame) assert df.empty def test_multi_stocks_panel_false(self, tmp_path): # 多股 panel=False → 长表含 code 列区分 db = tmp_path / "t.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", "SSE", "2024-06-19 00:00:00", "d", 1000, 1e6, 0, 900.0, 910.0, 890.0, 900.0), ("000001", "SZSE", "2024-06-19 00:00:00", "d", 1000, 1e6, 0, 10.0, 10.5, 9.8, 10.2), ] c.executemany("INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows) c.commit() c.close() p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) df = p.get_price( ["600519.XSHG", "000001.XSHE"], end_date="2024-06-19", count=1, panel=False, fields=["close"], ) assert len(df) == 2 assert set(df["code"]) == {"600519.XSHG", "000001.XSHE"} # ======================== Task 2: get_index_stocks ======================== def _make_constituent_db(tmp_path): db = tmp_path / "t.db" c = sqlite3.connect(str(db)) c.execute( "CREATE TABLE constituent_unified(index_code TEXT, code TEXT, code_name TEXT, " "source TEXT, in_current INT, was_removed INT)" ) c.executemany( "INSERT INTO constituent_unified VALUES(?,?,?,?,?,?)", [ ("000300", "600519", "贵州茅台", "baostock", 1, 0), ("000300", "000001", "平安银行", "baostock", 1, 0), ("000300", "600811", "退市股", "baostock", 0, 1), # 被踢 ], ) c.commit() c.close() return db class TestGetIndexStocks: def test_union_of_current_and_removed(self, tmp_path): db = _make_constituent_db(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) stocks = p.get_index_stocks("000300.XSHG", "2020-01-01") # 并集含被踢(was_removed=1) assert "600519.XSHG" in stocks assert "000001.XSHE" in stocks assert "600811.XSHG" in stocks # 6 开头 → SSE assert len(stocks) == 3 def test_date_param_ignored_union_model(self, tmp_path): # 并集模型 — date 参数不报错不过滤 db = _make_constituent_db(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) s1 = p.get_index_stocks("000300", "2010-01-01") s2 = p.get_index_stocks("000300", "2024-12-31") assert set(s1) == set(s2) def test_get_constituent_is_alias(self, tmp_path): db = _make_constituent_db(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) a = p.get_index_stocks("000300.XSHG", "2020-01-01") b = p.get_constituent("000300", "2020-01-01") assert a == b def test_index_not_found_returns_empty(self, tmp_path): db = _make_constituent_db(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) assert p.get_index_stocks("999999.XSHG", "2024-01-01") == [] def test_pure_digit_index_code(self, tmp_path): # 纯数字 index_symbol 也能查 db = _make_constituent_db(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) stocks = p.get_index_stocks("000300", "2020-01-01") assert len(stocks) == 3 # ======================== Task 3: get_fundamentals_df fixture ======================== def _make_fundamentals_fixture(tmp_path): """造 valuation_baostock + static/valuation + static/income + static/balance 样本。""" # 1. valuation_baostock/2024.parquet(baostock 权威: pe/pb/ps/pcf) vdir = tmp_path / "valuation_baostock" vdir.mkdir() pd.DataFrame({ "symbol": ["600519"], "exchange": ["SH"], "date": ["2024-09-30"], "peTTM": [25.0], "psTTM": [15.0], "pcfNcfTTM": [20.0], "pbMRQ": [7.5], "turn": [0.1], "pctChg": [1.0], "isST": [0], }).to_parquet(vdir / "2024.parquet") # 2. static/valuation akshare(市值/股本) sdir = tmp_path / "static" / "valuation" sdir.mkdir(parents=True) pd.DataFrame({ "数据日期": ["2024-09-30"], "总市值": [2e12], "流通市值": [1.5e12], "总股本": [1.256e9], "PE(TTM)": [25.0], "市净率": [7.5], }).to_parquet(sdir / "600519.SH_valuation.parquet") # 3. static/income akshare(eps + yoy + net_profit) idir = tmp_path / "static" / "income" idir.mkdir(parents=True) pd.DataFrame({ "SECUCODE": ["600519.SH"], "REPORT_DATE": ["2024-09-30"], "REPORT_TYPE": ["Q3"], "BASIC_EPS": [41.0], "OPERATE_INCOME": [3.7e10], "PARENT_NETPROFIT": [9.5e9], "OPERATE_INCOME_YOY": [15.0], "OPERATE_PROFIT_YOY": [14.0], }).to_parquet(idir / "600519.SH_income.parquet") # 4. static/balance akshare(资产/负债/权益) bdir = tmp_path / "static" / "balance" bdir.mkdir(parents=True) pd.DataFrame({ "SECUCODE": ["600519.SH"], "REPORT_DATE": ["2024-09-30"], "REPORT_TYPE": ["Q3"], "TOTAL_ASSETS": [2.5e11], "TOTAL_LIABILITIES": [5.4e10], "TOTAL_PARENT_EQUITY": [2.2e11], "SURPLUS_RESERVE": [8e10], "UNASSIGN_RPOFIT": [7e10], }).to_parquet(bdir / "600519.SH_balance.parquet") db = tmp_path / "t.db" c = sqlite3.connect(str(db)) c.execute("CREATE TABLE dbbardata(symbol TEXT, exchange TEXT, datetime TEXT, interval TEXT)") c.execute("INSERT INTO dbbardata VALUES('600519','SSE','2024-09-30 00:00:00','d')") c.commit() c.close() return db # ======================== Task 3: get_fundamentals_df ======================== class TestGetFundamentals: def test_pe_pb_from_baostock(self, tmp_path): db = _make_fundamentals_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) df = p.get_fundamentals_df(["600519.XSHG"], date="2024-09-30") assert not df.empty # baostock 权威: peTTM=25 / pbMRQ=7.5 assert abs(df.loc["600519.XSHG", "pe_ratio"] - 25.0) < 1e-6 assert abs(df.loc["600519.XSHG", "pb_ratio"] - 7.5) < 1e-6 assert abs(df.loc["600519.XSHG", "ps_ratio"] - 15.0) < 1e-6 assert abs(df.loc["600519.XSHG", "pcf_ratio"] - 20.0) < 1e-6 def test_market_cap_from_akshare(self, tmp_path): # baostock valuation 无市值列 → 从 static/valuation akshare 补 db = _make_fundamentals_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) df = p.get_fundamentals_df(["600519.XSHG"], date="2024-09-30") # 2e12 元 → 2e4 亿 assert abs(df.loc["600519.XSHG", "market_cap"] - 2e4) < 1 # 1.5e12 元 → 1.5e4 亿 assert abs(df.loc["600519.XSHG", "circulating_market_cap"] - 1.5e4) < 1 def test_three_tables_delegated(self, tmp_path): # 三表(income/balance)从 static akshare 读(委托 LocalParquetProvider) db = _make_fundamentals_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) df = p.get_fundamentals_df(["600519.XSHG"], date="2024-09-30") row = df.loc["600519.XSHG"] # eps 来自 income.BASIC_EPS assert abs(row["eps"] - 41.0) < 1e-6 # 总负债 5.4e10 元 → 540 亿 assert abs(row["total_liability"] - 540.0) < 1 # 归母权益 2.2e11 元 → 2200 亿 assert abs(row["total_sheet_owner_equities"] - 2200.0) < 1 # 留存收益 = 盈余公积 8e10 + 未分配利润 7e10 = 1.5e11 元 → 1500 亿 assert abs(row["retained_profit"] - 1500.0) < 1 # OPERATE_INCOME_YOY 15.0% → 0.15 assert abs(row["inc_revenue_year_on_year"] - 0.15) < 1e-6 def test_required_columns_present(self, tmp_path): db = _make_fundamentals_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) df = p.get_fundamentals_df(["600519.XSHG"], date="2024-09-30") # _FUNDAMENTAL_COLUMNS(对齐策略 all_weather) expected = [ "code", "market_cap", "circulating_market_cap", "pe_ratio", "pb_ratio", "ps_ratio", "pcf_ratio", "roe", "roa", "eps", "gross_profit_margin", "net_profit_margin", "inc_revenue_year_on_year", "inc_operation_profit_year_on_year", "inc_total_revenue_year_on_year", "total_liability", "total_sheet_owner_equities", "retained_profit", "roic", ] for col in expected: assert col in df.columns, f"missing col: {col}" def test_empty_stocks_returns_empty(self, tmp_path): db = _make_fundamentals_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) df = p.get_fundamentals_df([], date="2024-09-30") assert df.empty # ======================== Task 3b: get_fundamentals_df fields= 按需短路 + 并发 ======================== class TestGetFundamentalsFields: """fields= 只读必需表(跳 balance/financial_abstract/roic) + ThreadPool 并发,零 VPS 回归。 动机:策略 02 _pick_stocks 对 5128 只按 market_cap+eps 排序,旧实现逐只读 4 表 + 算 roic → 首仓卡死。fields=['market_cap','eps'] 只读 valuation+income,并发逐只。 """ def test_fields_subset_columns_and_values(self, tmp_path): db = _make_fundamentals_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) df = p.get_fundamentals_df( ["600519.XSHG"], date="2024-09-30", fields=["market_cap", "eps"] ) # 只返 code + 请求列 assert list(df.columns) == ["code", "market_cap", "eps"] assert abs(df.loc["600519.XSHG", "market_cap"] - 2e4) < 1 assert abs(df.loc["600519.XSHG", "eps"] - 41.0) < 1e-6 def test_fields_skips_unneeded_tables(self, tmp_path, monkeypatch): # fields=['market_cap','eps'] 只需 valuation(akshare)+income → 不读 balance/financial_abstract from sanguo_portfolio.providers import local_parquet_provider as lpp_mod db = _make_fundamentals_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) orig_q = lpp_mod.LocalParquetProvider._read_quarter orig_fa = lpp_mod.LocalParquetProvider._read_financial_abstract tables_read: List[str] = [] fa_called = {"v": False} def spy_q(self, table, file_code): # noqa: ANN001 tables_read.append(table) return orig_q(self, table, file_code) def spy_fa(self, file_code): # noqa: ANN001 fa_called["v"] = True return orig_fa(self, file_code) monkeypatch.setattr(lpp_mod.LocalParquetProvider, "_read_quarter", spy_q) monkeypatch.setattr(lpp_mod.LocalParquetProvider, "_read_financial_abstract", spy_fa) df = p.get_fundamentals_df( ["600519.XSHG"], date="2024-09-30", fields=["market_cap", "eps"] ) assert "income" in tables_read assert "balance" not in tables_read assert fa_called["v"] is False assert abs(df.loc["600519.XSHG", "eps"] - 41.0) < 1e-6 def test_fields_none_backward_compat(self, tmp_path): # fields=None 仍返全部 _FUNDAMENTAL_COLUMNS(回归: roe/roa/roic/gross_profit_margin 仍在) db = _make_fundamentals_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) df = p.get_fundamentals_df(["600519.XSHG"], date="2024-09-30") for col in ("roe", "roa", "roic", "gross_profit_margin", "total_liability"): assert col in df.columns def test_threadpool_preserves_values_and_order(self, tmp_path, monkeypatch): # >阈值触发 ThreadPool: 70 只(1 有数据 + 69 缺失)→ 值正确 + 顺序保持 import sanguo_portfolio.providers.local_unified_provider as up monkeypatch.setattr(up, "_FUND_POOL_THRESHOLD", 1) db = _make_fundamentals_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) stocks = ["600519.XSHG"] + [f"00000{i}.XSHE" for i in range(1, 70)] df = p.get_fundamentals_df(stocks, date="2024-09-30") assert len(df) == 70 assert df.index[0] == "600519.XSHG" # ex.map 保序 assert abs(df.loc["600519.XSHG", "eps"] - 41.0) < 1e-6 assert pd.isna(df.loc["000001.XSHE", "eps"]) # 缺失股票 eps NaN # ======================== Task 3c: get_security_info_batch / get_value_metrics_batch ======================== def _make_security_info_fixture(tmp_path): """dbbardata(3 只日线, 含碰撞 SSE/SZSE) + constituent_unified(含 1 ST) 供 batch 测试。""" db = tmp_path / "s.db" c = sqlite3.connect(str(db)) c.execute( "CREATE TABLE dbbardata(symbol TEXT, exchange TEXT, datetime TEXT, " "interval TEXT, close_price REAL)" ) c.executemany("INSERT INTO dbbardata VALUES(?,?,?,?,?)", [ ("600519", "SSE", "2024-06-18 00:00:00", "d", 1500.0), ("600519", "SSE", "2024-06-20 00:00:00", "d", 1510.0), ("000001", "SZSE", "2023-01-03 00:00:00", "d", 12.0), ("000001", "SZSE", "2024-06-20 00:00:00", "d", 11.0), ("000002", "SZSE", "2024-06-19 00:00:00", "d", 8.0), ]) c.execute("CREATE TABLE constituent_unified(code TEXT, code_name TEXT)") c.executemany("INSERT INTO constituent_unified VALUES(?,?)", [ ("600519", "贵州茅台"), ("000001", "平安银行"), ("000002", "*ST某某"), ]) c.commit() c.close() return db class TestGetSecurityInfoBatch: """get_security_info_batch: 2 条 SQL 替 N×2 逐只(filters ST/次新通病)。""" def test_batch_matches_per_stock(self, tmp_path): db = _make_security_info_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) codes = ["600519.XSHG", "000001.XSHE", "000002.XSHE", "999999.XSHG"] batch = p.get_security_info_batch(codes) assert set(batch.keys()) == set(codes) for code in codes: # 核心回归: batch == 逐只 assert batch[code] == p.get_security_info(code), f"mismatch {code}" assert batch["600519.XSHG"]["start_date"] == "2024-06-18" assert batch["600519.XSHG"]["end_date"] == "2024-06-20" assert "*ST" in batch["000002.XSHE"]["display_name"] # 名字从 constituent_unified assert batch["999999.XSHG"]["start_date"] is None # 缺数据 def test_batch_empty(self, tmp_path): db = _make_security_info_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) assert p.get_security_info_batch([]) == {} class TestGetValueMetricsBatch: """get_value_metrics_batch: ThreadPool 并发逐只(策略01 价值精选), 与逐只一致。""" @staticmethod def _norm(d): """NaN 容错规范化(NaN!=NaN 会让 dict==False; 用 'NaN' 占位)。""" if d is None: return None out = {} for k, v in d.items(): if isinstance(v, list): out[k] = ["NaN" if (isinstance(x, float) and x != x) else x for x in v] elif isinstance(v, float) and v != v: out[k] = "NaN" else: out[k] = v return out def test_batch_matches_per_stock(self, tmp_path): db = _make_fundamentals_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) codes = ["600519.XSHG", "999999.XSHG"] batch = p.get_value_metrics_batch(codes, date="2024-09-30") assert set(batch.keys()) == set(codes) for code in codes: # 核心回归: batch == 逐只(多期 dict, NaN 容错) assert self._norm(batch[code]) == self._norm( p.get_value_metrics(code, date="2024-09-30") ), code # ======================== Task 3d: get_limit_status_batch (涨跌停/停牌回测修正) ======================== def _make_limit_fixture(tmp_path): """dbbardata 2 日线(T-1=06-19, T=06-20)覆盖涨停/跌停/停牌/创业板20%/正常/缺失。 symbol, exchange, datetime, interval, close_price, high_price, low_price, volume """ db = tmp_path / "lim.db" c = sqlite3.connect(str(db)) c.execute( "CREATE TABLE dbbardata(symbol TEXT, exchange TEXT, datetime TEXT, " "interval TEXT, close_price REAL, high_price REAL, low_price REAL, volume REAL)" ) rows = [ # 600001 主板: prev=10, T close=11.0(=round(10*1.1,2)) → 涨停 ("600001", "SSE", "2024-06-19 00:00:00", "d", 10.0, 10.0, 10.0, 1000), ("600001", "SSE", "2024-06-20 00:00:00", "d", 11.0, 11.0, 11.0, 1000), # 600002 主板: prev=10, T close=9.0(=round(10*0.9,2)) → 跌停 ("600002", "SSE", "2024-06-19 00:00:00", "d", 10.0, 10.0, 10.0, 1000), ("600002", "SSE", "2024-06-20 00:00:00", "d", 9.0, 9.0, 9.0, 1000), # 600003 主板: T vol=0 → 停牌 ("600003", "SSE", "2024-06-19 00:00:00", "d", 10.0, 10.0, 10.0, 1000), ("600003", "SSE", "2024-06-20 00:00:00", "d", 10.0, 10.0, 10.0, 0), # 300001 创业板: prev=10, T close=12.0(=round(10*1.2,2)) → 涨停(20%) ("300001", "SZSE", "2024-06-19 00:00:00", "d", 10.0, 10.0, 10.0, 1000), ("300001", "SZSE", "2024-06-20 00:00:00", "d", 12.0, 12.0, 12.0, 1000), # 600004 主板: prev=10, T close=10.5 → 正常(非涨跌停) ("600004", "SSE", "2024-06-19 00:00:00", "d", 10.0, 10.0, 10.0, 1000), ("600004", "SSE", "2024-06-20 00:00:00", "d", 10.5, 10.6, 10.4, 1000), ] c.executemany("INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?)", rows) c.commit() c.close() return db class TestGetLimitStatusBatch: """get_limit_status_batch: 回测当日涨跌停/停牌(修 filter 失效致假收益)。 口径: high_limit=round(prev_close×(1+幅度),2); close>=high_limit→涨停; volume==0→停牌; 幅度=主板10/创业·科创20/北交30/ST5。 """ def test_limit_status(self, tmp_path): db = _make_limit_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) codes = [ "600001.XSHG", "600002.XSHG", "600003.XSHG", "300001.XSHE", "600004.XSHG", "999999.XSHG", ] out = p.get_limit_status_batch(codes, date="2024-06-20") assert set(out.keys()) == set(codes) assert out["600001.XSHG"] == {"is_limit_up": True, "is_limit_down": False, "is_paused": False} assert out["600002.XSHG"] == {"is_limit_up": False, "is_limit_down": True, "is_paused": False} assert out["600003.XSHG"]["is_paused"] is True # vol=0 assert out["300001.XSHE"] == {"is_limit_up": True, "is_limit_down": False, "is_paused": False} # 创业板20% assert out["600004.XSHG"] == {"is_limit_up": False, "is_limit_down": False, "is_paused": False} assert out["999999.XSHG"] is None # 无 bar def test_empty(self, tmp_path): db = _make_limit_fixture(tmp_path) p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) assert p.get_limit_status_batch([], date="2024-06-20") == {} # ======================== Task 4: 辅助方法 ======================== class TestAuxMethods: def test_get_trade_days_from_dbbardata(self, unified_provider): # dbbardata 600519 三根日线 → 3 个交易日 days = unified_provider.get_trade_days( start_date="2024-06-18", end_date="2024-06-20" ) assert len(days) == 3 assert all(hasattr(d, "year") for d in days) def test_get_trade_days_count(self, unified_provider): days = unified_provider.get_trade_days(count=2) assert len(days) == 2 def test_get_security_info(self, unified_provider): info = unified_provider.get_security_info("600519.XSHG") assert info["code"] == "600519.XSHG" assert "start_date" in info assert "end_date" in info def test_get_current_tick_high_limit(self, unified_provider): # dbbardata 最近 close=910 → high_limit=910*1.1=1001 tick = unified_provider.get_current_tick("600519.XSHG") assert tick is not None assert abs(tick["close"] - 910.0) < 1e-6 assert abs(tick["high_limit"] - 910.0 * 1.1) < 1e-2 def test_get_split_dividend_from_adjust_factor(self, unified_provider): # bs_adjust_factor 有 1 个事件 → 1 条记录 events = unified_provider.get_split_dividend( "600519.XSHG", start_date="2024-01-01", end_date="2024-12-31" ) assert len(events) >= 1 assert "date" in events[0] def test_get_all_securities_from_dbbardata(self, tmp_path): # dbbardata distinct symbol → DataFrame db = tmp_path / "t.db" c = sqlite3.connect(str(db)) c.execute( "CREATE TABLE dbbardata(symbol TEXT, exchange TEXT, datetime TEXT, interval TEXT)" ) c.executemany( "INSERT INTO dbbardata VALUES(?,?,?,?)", [ ("600519", "SSE", "2024-06-19 00:00:00", "d"), ("000001", "SZSE", "2024-06-19 00:00:00", "d"), ], ) c.commit() c.close() p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) df = p.get_all_securities() assert len(df) == 2 assert "code" in df.columns assert "600519.XSHG" in set(df["code"]) # ======================== 混合 datetime 格式(VPS 真实数据特性) ======================== class TestMixedDatetimeFormat: """dbbardata datetime 列混合格式(有只日期有带时间,不同 schtask/迁移写入)。 VPS E2E 实证: ``"2024-09-26"`` 与 ``"2024-09-26 00:00:00"`` 混存。 pandas 2.3 严格模式要 ``format='mixed'``(Mac 统一格式 fixture 盲区)。 """ def test_get_price_mixed_datetime_format(self, tmp_path): db = tmp_path / "t.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)" ) c.executemany( "INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)", [ ("600519", "SSE", "2024-09-25", "d", 1000, 1e6, 0, 1000.0, 1010.0, 990.0, 1000.0), ("600519", "SSE", "2024-09-26 00:00:00", "d", 1100, 1.1e6, 0, 1005.0, 1015.0, 995.0, 1010.0), ("600519", "SSE", "2024-09-27", "d", 1200, 1.2e6, 0, 1010.0, 1020.0, 1000.0, 1015.0), ], ) c.commit() c.close() p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)}) df = p.get_price("600519.XSHG", start_date="2024-09-25", end_date="2024-09-27", fq="raw") assert len(df) == 3 # 不崩 + 返 3 行(混合格式解析 OK)