diff --git a/backend/app/services/market_overview_builder.py b/backend/app/services/market_overview_builder.py index f879fb4..9f65931 100644 --- a/backend/app/services/market_overview_builder.py +++ b/backend/app/services/market_overview_builder.py @@ -237,6 +237,16 @@ def _symbol_keys(row: dict, config: ExtConfig) -> list[str]: return keys +def _leader_sort_key(row: dict) -> float: + """领涨股排序键: 缺涨跌幅的成分股排最后。 + + 0.00% 是有效涨跌幅, 不能与"无行情"合并成同一个哨兵值 —— 板块整体下跌时 + 平盘股就是领涨股。 + """ + value = _finite(row.get("change_pct")) + return value if value is not None else float("-inf") + + def _dimension_rank(rows: list[dict], repo, kind: str, limit: int = 5, level: int | None = None) -> dict: if not rows: return {"leading": [], "lagging": []} @@ -281,7 +291,7 @@ def _dimension_rank(rows: list[dict], repo, kind: str, limit: int = 5, level: in changes = [v for v in changes if v is not None] if not changes: continue - leader = max(stocks, key=lambda s: _finite(s.get("change_pct")) or -999) + leader = max(stocks, key=_leader_sort_key) items.append({ "name": name, "count": len(stocks), diff --git a/backend/tests/test_overview_dimension_leader.py b/backend/tests/test_overview_dimension_leader.py new file mode 100644 index 0000000..004b4ef --- /dev/null +++ b/backend/tests/test_overview_dimension_leader.py @@ -0,0 +1,89 @@ +"""市场总览「领涨股」选取: 涨跌幅 0.00% 是有效值, 不能被当成缺失值。""" +from __future__ import annotations + +import json + +import polars as pl + +from app.services.market_overview_builder import _dimension_rank + + +def _fake_repo(tmp_path): + import types + + return types.SimpleNamespace(store=types.SimpleNamespace(data_dir=tmp_path)) + + +def _write_concept_ext(tmp_path, mapping: dict[str, str]) -> None: + """写一张 snapshot 模式的概念扩展表 (symbol → 所属概念)。""" + cfg_dir = tmp_path / "ext_data" / "concept_tbl" + cfg_dir.mkdir(parents=True, exist_ok=True) + cfg_dir.joinpath("config.json").write_text( + json.dumps({ + "id": "concept_tbl", + "label": "概念表", + "mode": "snapshot", + "fields": [{"name": "所属概念", "dtype": "string", "label": "所属概念"}], + }, ensure_ascii=False), + encoding="utf-8", + ) + pl.DataFrame({ + "symbol": list(mapping.keys()), + "所属概念": list(mapping.values()), + }).write_parquet(cfg_dir / "part.parquet") + + +def test_flat_stock_can_be_leader_of_a_falling_concept(tmp_path): + """全概念下跌、最强的一只恰好平盘(0.00%)时, 领涨股必须是那只平盘股。""" + _write_concept_ext(tmp_path, { + "000001.SZ": "人工智能", + "000002.SZ": "人工智能", + "000003.SZ": "人工智能", + }) + rows = [ + {"symbol": "000001.SZ", "name": "跌一", "change_pct": -0.01, "amount": 1e8}, + {"symbol": "000002.SZ", "name": "平盘", "change_pct": 0.0, "amount": 2e8}, + {"symbol": "000003.SZ", "name": "跌三", "change_pct": -0.03, "amount": 3e8}, + ] + + result = _dimension_rank(rows, _fake_repo(tmp_path), "concept") + + items = {item["name"]: item for item in result["lagging"]} + assert "人工智能" in items + leader = items["人工智能"]["leader"] + assert leader["name"] == "平盘" + assert leader["change_pct"] == 0.0 + + +def test_leader_falls_back_to_none_pct_last(tmp_path): + """change_pct 缺失(None)的成分股仍排在所有有值的成分股之后。""" + _write_concept_ext(tmp_path, { + "000001.SZ": "芯片", + "000002.SZ": "芯片", + }) + rows = [ + {"symbol": "000001.SZ", "name": "无行情", "change_pct": None, "amount": 1e8}, + {"symbol": "000002.SZ", "name": "微跌", "change_pct": -0.02, "amount": 1e8}, + ] + + result = _dimension_rank(rows, _fake_repo(tmp_path), "concept") + + items = {item["name"]: item for item in result["lagging"]} + assert items["芯片"]["leader"]["name"] == "微跌" + + +def test_leader_still_picks_max_when_all_positive(tmp_path): + """普通情形不受影响: 全部上涨时仍取涨幅最大的一只。""" + _write_concept_ext(tmp_path, { + "000001.SZ": "光伏", + "000002.SZ": "光伏", + }) + rows = [ + {"symbol": "000001.SZ", "name": "涨多", "change_pct": 0.05, "amount": 1e8}, + {"symbol": "000002.SZ", "name": "涨少", "change_pct": 0.01, "amount": 1e8}, + ] + + result = _dimension_rank(rows, _fake_repo(tmp_path), "concept") + + items = {item["name"]: item for item in result["leading"]} + assert items["光伏"]["leader"]["name"] == "涨多"