feat(portfolio): LocalUnifiedProvider 成份股并集治偏差(Task2)

This commit is contained in:
2026-07-23 08:07:37 +08:00
parent 4b4dca2f44
commit b46a0c26c8
2 changed files with 102 additions and 0 deletions
@@ -228,3 +228,45 @@ class LocalUnifiedProvider(DataProvider): # type: ignore[misc]
if len(frames) == 1:
return next(iter(frames.values()))
return pd.concat(frames, axis=1)
# ==================== get_index_stocks (constituent_unified 并集) ====================
def get_index_stocks(
self,
index_symbol: str,
date: Optional[Union[str, datetime]] = None,
) -> List[str]:
"""读 ``constituent_unified`` 并集(``in_current=1 OR was_removed=1``)。
⚠️ 并集模型: 表无 date 列,治"纯当前"幸存者偏差(含已退市/被踢),
但有轻微前视(使用说明标注)。``date`` 参数忽略(无时点数据)。
"""
idx = (
index_symbol.split(".")[0]
if "." in str(index_symbol) else str(index_symbol)
)
conn = self._connect()
try:
rows = conn.execute(
"SELECT code FROM constituent_unified WHERE index_code=? "
"AND (in_current=1 OR was_removed=1)",
(idx,),
).fetchall()
except sqlite3.Error as exc:
logger.warning("constituent_unified 查询失败 %s: %s", idx, exc)
return []
out: List[str] = []
for (code,) in rows:
code_str = str(code).strip()
if len(code_str) != 6 or not code_str.isdigit():
continue
exc_name = "SSE" if code_str.startswith("6") else "SZSE"
out.append(dbbardata_to_jq(code_str, exc_name))
return out
def get_constituent(
self,
index: str,
date: Optional[Union[str, datetime]] = None,
) -> List[str]:
"""spec §6 语义别名 = ``get_index_stocks``。"""
return self.get_index_stocks(index, date)
@@ -280,3 +280,63 @@ class TestGetPrice:
)
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