feat(portfolio): get_security_info_batch + get_value_metrics_batch 批量接口

策略层提速第三轮(filters 通病 + 策略01):
- get_security_info_batch: 2 条 SQL(symbol IN chunk + GROUP BY sym,exc 拿
  min/max; constituent_unified 拿 name)替 N×2 逐只。filters.py filter_st_stock/
  filter_new_stock 自动探测批量(hasattr + isinstance dict 回退逐只, 向后兼容)。
  三策略 ST/次新过滤通病: 万次查询压成 2 条。
- get_value_metrics_batch: ThreadPool 并发逐只委托 lpp.get_value_metrics
  (多期 ROE/FCF/流动比率逻辑不变, 只并发)。策略01 价值精选提速。
- 接口3 get_ticks_batch 不做: 实证 get_current_tick 无 last_price/paused 字段
  → 回测涨跌停/停牌 filter 恒不过滤(死代码), 批量化无意义; 真问题是回测
  涨跌停检测失效(策略层另修)。

Mac TDD parity 测试全绿(batch==逐只); 93 回归通过。
This commit is contained in:
2026-07-29 08:23:21 +08:00
parent 32dbcb8958
commit d2cd8fa945
3 changed files with 197 additions and 4 deletions
@@ -546,6 +546,83 @@ class TestGetFundamentalsFields:
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 4: 辅助方法 ========================
class TestAuxMethods:
def test_get_trade_days_from_dbbardata(self, unified_provider):