Files
tick-stock-panel/backend/tests/test_market_mainline.py
T
shy3130 697c27bb02 feat(v0.2): 市场阶段与主线识别 + 因子挖掘全链路 + 数据层完善
- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动,
  EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合,
  可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存
- 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档),
  周度调度默认关闭且永不自动发布
- 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益,
  信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错)
- 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复
- 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
2026-08-16 23:39:07 +08:00

163 lines
7.5 KiB
Python

"""市场主线(market_mainline)与过滤配置单元测试。"""
from __future__ import annotations
from datetime import date
import polars as pl
from app.services import market_mainline, preferences
def _write_enriched(root, rows: list[dict]) -> None:
enriched = root / "kline_daily_enriched"
by_date: dict[date, list[dict]] = {}
for r in rows:
by_date.setdefault(r["date"], []).append(r)
for d, day_rows in by_date.items():
part = enriched / f"date={d.isoformat()}" / "part.parquet"
part.parent.mkdir(parents=True, exist_ok=True)
pl.DataFrame(day_rows).write_parquet(part)
def _fake_repo(tmp_path):
import types
return types.SimpleNamespace(store=types.SimpleNamespace(data_dir=tmp_path))
def _patch_map(monkeypatch, mapping: dict[str, list[str]], kind: str = "concept") -> None:
map_df = pl.DataFrame(
{"_sym_up": [s for s, ms in mapping.items() for _ in ms],
kind: [m for _, ms in mapping.items() for m in ms]},
schema={"_sym_up": pl.Utf8, kind: pl.Utf8},
).unique()
def fake_load(repo, k="concept"):
return (map_df, map_df[kind].n_unique()) if k == kind else (pl.DataFrame(), 0)
monkeypatch.setattr(market_mainline, "_load_concept_map_df", fake_load)
def _mk_rows(d: date, spec: list[tuple[str, int, float]]) -> list[dict]:
return [
{"symbol": sym, "date": d, "consecutive_limit_ups": consec, "amount": amt}
for sym, consec, amt in spec
]
class TestComputeMainline:
def _setup(self, tmp_path, monkeypatch):
d1, d2 = date(2024, 1, 2), date(2024, 1, 3)
# 概念 X: d1 三个涨停(2,1,1), d2 三个涨停(3,2,1); 概念 Y: 单股 2 板
# S5 无概念映射; 大概念 BIG 成员 700 家但只有 5 家涨停(数据里只写 5 行)
rows = _mk_rows(d1, [("S1.SH", 2, 5e8), ("S2.SH", 1, 1e8), ("S3.SH", 1, 2e8),
("S4.SH", 2, 3e8), ("S5.SH", 1, 1e8),
("B1.SH", 1, 1e8), ("B2.SH", 1, 1e8)])
rows += _mk_rows(d2, [("S1.SH", 3, 6e8), ("S2.SH", 2, 2e8), ("S3.SH", 0, 1e8),
("S4.SH", 3, 4e8), ("S5.SH", 1, 1e8),
("B1.SH", 2, 1e8), ("B2.SH", 0, 1e8)])
_write_enriched(tmp_path, rows)
mapping = {
"S1.SH": ["X"], "S2.SH": ["X"], "S3.SH": ["X"],
"S4.SH": ["X", "Y"], "S5.SH": [],
"B1.SH": ["BIG"], "B2.SH": ["BIG"],
**{f"F{i}.SH": ["BIG"] for i in range(700)}, # BIG 成员 702 → 超 600 上限
}
_patch_map(monkeypatch, mapping)
return _fake_repo(tmp_path), d1, d2
def test_aggregation_and_big_concept_filter(self, tmp_path, monkeypatch):
repo, d1, d2 = self._setup(tmp_path, monkeypatch)
out = market_mainline.compute_mainline_range(
repo, tmp_path, d1, d2, kind="concept",
filter_cfg={"min_members": 4, "max_members": 600, "blacklist": []},
)
members = set(out["member"].to_list())
assert "BIG" not in members # 成员数超上限被过滤
assert "X" in members
x_d2 = out.filter((pl.col("date") == d2) & (pl.col("member") == "X")).to_dicts()[0]
assert x_d2["limit_up_count"] == 3 # S1,S2,S4
assert x_d2["ge2_count"] == 3
assert x_d2["max_boards"] == 3
assert x_d2["rungs_filled"] == 2 # 档位 {2,3}
assert x_d2["leader_symbol"] == "S1.SH" # 最高板且成交额大
assert x_d2["rank"] == 1
def test_blacklist_and_min_limit_up(self, tmp_path, monkeypatch):
repo, d1, d2 = self._setup(tmp_path, monkeypatch)
out = market_mainline.compute_mainline_range(
repo, tmp_path, d1, d2, kind="concept",
filter_cfg={"min_members": 1, "max_members": 5000, "blacklist": ["X"]},
)
# X 被黑名单; BIG 只有 2-3 家涨停 < _MIN_LIMIT_UP=3 也不参与 → 只剩空/无 X
assert "X" not in set(out["member"].to_list())
def test_upsert_replaces_same_day_kind(self, tmp_path, monkeypatch):
repo, d1, d2 = self._setup(tmp_path, monkeypatch)
cfg = {"min_members": 4, "max_members": 600, "blacklist": []}
first = market_mainline.compute_mainline_range(repo, tmp_path, d1, d1, kind="concept", filter_cfg=cfg)
market_mainline.upsert_mainline_history(tmp_path, first)
both = market_mainline.compute_mainline_range(repo, tmp_path, d1, d2, kind="concept", filter_cfg=cfg)
market_mainline.upsert_mainline_history(tmp_path, both)
stored = pl.read_parquet(market_mainline.mainline_path(tmp_path))
assert set(stored["date"].to_list()) == {d1, d2}
# 同日重算不产生重复行
assert stored.filter(pl.col("date") == d1).height == first.height
def test_incremental_fills_missing_days(self, tmp_path, monkeypatch):
repo, d1, d2 = self._setup(tmp_path, monkeypatch)
cfg = {"min_members": 4, "max_members": 600, "blacklist": []}
first = market_mainline.compute_mainline_range(repo, tmp_path, d1, d1, kind="concept", filter_cfg=cfg)
market_mainline.upsert_mainline_history(tmp_path, first)
new = market_mainline.compute_mainline_incremental(repo, tmp_path, kind="concept")
assert not new.is_empty()
assert set(new["date"].to_list()) == {d2}
def test_industry_level_truncation(self, tmp_path, monkeypatch):
d1 = date(2024, 1, 2)
rows = _mk_rows(d1, [("S1.SH", 2, 5e8), ("S2.SH", 1, 1e8),
("S3.SH", 1, 2e8), ("S4.SH", 3, 4e8)])
_write_enriched(tmp_path, rows)
_patch_map(
monkeypatch,
{"S1.SH": ["计算机-软件开发-垂直应用软件"],
"S2.SH": ["计算机-软件开发-垂直应用软件"],
"S3.SH": ["计算机-IT服务-IT服务Ⅲ"],
"S4.SH": ["计算机-软件开发-垂直应用软件"]},
kind="industry",
)
out = market_mainline.compute_mainline_range(
_fake_repo(tmp_path), tmp_path, d1, d1, kind="industry",
filter_cfg={"min_members": 1, "max_members": 5000, "blacklist": []},
)
members = set(out["member"].to_list())
assert "计算机-软件开发" in members
assert all(m.count("-") <= 1 for m in members)
sw = out.filter(pl.col("member") == "计算机-软件开发").to_dicts()[0]
assert sw["limit_up_count"] == 3
assert sw["max_boards"] == 3
class TestMainlineFilterPreferences:
def test_blacklist_string_parsing_and_clamp(self, tmp_path, monkeypatch):
path = tmp_path / "preferences.json"
monkeypatch.setattr(preferences, "_path", lambda: path)
got = preferences.set_mainline_filter_config({
"max_members": 99999, # 超上限被夹到 5000
"min_members": 0, # 低于下限被夹到 1
"blacklist": "融资融券, 沪股通;深股通", # noqa: RUF001
})
assert got["max_members"] == 5000
assert got["min_members"] == 1
assert set(got["blacklist"]) == {"融资融券", "沪股通", "深股通"}
# 部分更新: 只改黑名单, 其他保持
got2 = preferences.set_mainline_filter_config({"blacklist": ["ST板块"]})
assert got2["blacklist"] == ["ST板块"]
assert got2["max_members"] == 5000
def test_defaults(self, tmp_path, monkeypatch):
path = tmp_path / "preferences.json"
monkeypatch.setattr(preferences, "_path", lambda: path)
cfg = preferences.get_mainline_filter_config()
assert cfg == {"min_members": 4, "max_members": 600, "blacklist": []}