Files
sanguo_vnpy_v2/tests/portfolio/test_local_unified_provider.py
T
claude_dev 384bcc56d7 fix(data): 修三环境 session 反馈的 3 个数据层问题
D1: 删 test_circuit_breaker.py(测已归档 raw_redownload.check_circuit_breaker 死代码,全仓零活跃引用,致 data_platform 套件 collection error)
D2: datareader.py read_db_daily/read_index_daily 两处 vnpy_db 硬访问→.get()+清晰报错防崩溃(根治切 dbbardata 读指数列待办)
D3: high_limit/low_limit close±10% 兜底是有意设计非 bug(填 NaN 会复活 bullet_trade 误判停牌)—get_price 加 round(.,2) 对齐 get_current_tick 口径;测试期望从 NaN 改兜底估算
2026-07-29 21:17:15 +08:00

778 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)