diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 8e062b8..d8536e7 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -966,16 +966,20 @@ class PipelineIndexSymbolsIn(BaseModel): class MainlineFilterIn(BaseModel): - """市场主线过滤配置(宽基/风格标签按成员数过滤 + 名称黑名单)。""" + """市场主线过滤配置(宽基/风格标签按成员数过滤 + 名称黑名单 + ST 剔除开关)。""" min_members: int | None = None max_members: int | None = None blacklist: list[str] | str | None = None + exclude_st: bool | None = None @router.put("/preferences/mainline-filter") def update_mainline_filter(req: MainlineFilterIn) -> dict: - """更新市场主线过滤配置。部分更新; 修改后需重算主线(POST /api/regime/mainline/recompute)生效。""" + """更新市场主线过滤配置。部分更新; 修改后需重算主线(POST /api/regime/mainline/recompute)生效。 + + exclude_st 同步控制市场环境(regime)统计口径 — 切换后需全量重算 regime。 + """ from app.services import preferences payload = req.model_dump() diff --git a/backend/app/services/market_mainline.py b/backend/app/services/market_mainline.py index 2344957..9ea0418 100644 --- a/backend/app/services/market_mainline.py +++ b/backend/app/services/market_mainline.py @@ -15,6 +15,7 @@ join 概念映射后 group_by, 峰值内存 <100MB。 from __future__ import annotations import logging +import time from datetime import date from pathlib import Path @@ -68,6 +69,35 @@ def mainline_path(data_dir: Path) -> Path: return data_dir / MAINLINE_DIR / "part.parquet" +_ST_SYMBOLS_CACHE: tuple[float, frozenset[str]] | None = None + + +def load_risk_warning_symbols(data_dir: Path) -> frozenset[str]: + """当前维表快照中名称含 ST 标记的 symbol 集合(大写), 供主线/情绪统计剔除。 + + 判定与 indicators 涨跌停口径共用同一权威实现(price_limits.polars_is_risk_warning_name, + 即名称含 "ST", 覆盖 ST/*ST/S*ST)。维表是快照无历史版本, 与概念成分同样的 + 回看限制。600s 进程内缓存(维表 snapshot 进程内不变)。 + """ + global _ST_SYMBOLS_CACHE + now = time.time() + if _ST_SYMBOLS_CACHE is not None and now - _ST_SYMBOLS_CACHE[0] < 600: + return _ST_SYMBOLS_CACHE[1] + from app.price_limits import polars_is_risk_warning_name + + syms: frozenset[str] = frozenset() + inst_dir = data_dir / "instruments" + if inst_dir.exists(): + try: + df = pl.read_parquet(inst_dir / "**" / "*.parquet").select(["symbol", "name"]) + st = df.filter(polars_is_risk_warning_name(pl.col("name"))) + syms = frozenset(s.upper() for s in st["symbol"].to_list()) + except Exception as e: + logger.warning("load risk-warning symbols failed: %s", e) + _ST_SYMBOLS_CACHE = (now, syms) + return syms + + def load_mainline_history(data_dir: Path, kind: str = "concept") -> pl.DataFrame: """读取主线时序(全部 kind), 不存在返回空 DataFrame。""" p = mainline_path(data_dir) @@ -92,12 +122,15 @@ def _industry_member(member: str, kind: str) -> str: def compute_mainline_range(repo, data_dir: Path, start: date, end: date, kind: str = "concept", - filter_cfg: dict | None = None) -> pl.DataFrame: + filter_cfg: dict | None = None, + exclude_st: bool | None = None) -> pl.DataFrame: """计算 [start, end] 每日主线排行(按 _SCORE_WEIGHTS 加权截面分)。 filter_cfg: {"min_members", "max_members", "blacklist"}; None 时读用户偏好。 宽基/风格标签(融资融券/沪深股通等数千成分)按成员数上限过滤, 用户黑名单按名称过滤(不论大小)。修改配置后重算主线生效。 + exclude_st: 是否剔除风险警示(ST)股(按当前维表名称); None 时读用户偏好 + (默认剔除 — ST 是状态桶非题材, 主板 5% 便宜板时代曾系统性霸榜)。 返回列: date, kind, member, limit_up_count, ge2_count, max_boards, boards_sum, rungs_filled, leader_symbol, score, rank。空数据返回空表。 @@ -138,6 +171,19 @@ def compute_mainline_range(repo, data_dir: Path, start: date, end: date, return pl.DataFrame() limit_rows = limit_rows.with_columns(pl.col("symbol").str.to_uppercase().alias("_sym_up")) + + # 剔除风险警示股: ST 板块的涨停生态(主板曾 5% 便宜板)不代表题材主线。 + if exclude_st is None: + try: + from app.services import preferences + exclude_st = preferences.get_sentiment_exclude_st() + except Exception: + exclude_st = True + if exclude_st: + st_syms = load_risk_warning_symbols(repo.store.data_dir) + if st_syms: + limit_rows = limit_rows.filter(~pl.col("_sym_up").is_in(sorted(st_syms))) + joined = limit_rows.join(map_df, on="_sym_up", how="inner") if joined.is_empty(): return pl.DataFrame() diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 43fc60e..f5470ff 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -385,12 +385,28 @@ def get_mainline_blacklist() -> list[str]: return [str(x).strip() for x in v if str(x).strip()] +def get_sentiment_exclude_st() -> bool: + """市场环境/主线统计是否剔除风险警示(ST)股。默认 True。 + + 口径: 主板 ST 在 2026-07 前享 5% 涨跌幅(封板成本减半), 且 ST 是跨行业的 + 状态桶而非投资题材, 混入会系统性抬高涨停宽度/高度(弱市尤甚)。剔除后 + 涨跌家数等宽度占比几乎不受影响。修改后需重算 regime 与主线生效。 + """ + return bool(load().get("sentiment_exclude_st", True)) + + +def set_sentiment_exclude_st(v: bool) -> bool: + save({"sentiment_exclude_st": bool(v)}) + return get_sentiment_exclude_st() + + def get_mainline_filter_config() -> dict: """主线过滤配置汇总(供 API 返回与计算读取)。""" return { "min_members": get_mainline_min_members(), "max_members": get_mainline_max_members(), "blacklist": get_mainline_blacklist(), + "exclude_st": get_sentiment_exclude_st(), } @@ -401,6 +417,8 @@ def set_mainline_filter_config(cfg: dict) -> dict: updates["mainline_min_members"] = cfg["min_members"] if "max_members" in cfg and cfg["max_members"] is not None: updates["mainline_max_members"] = cfg["max_members"] + if "exclude_st" in cfg and cfg["exclude_st"] is not None: + updates["sentiment_exclude_st"] = bool(cfg["exclude_st"]) if "blacklist" in cfg and cfg["blacklist"] is not None: raw = cfg["blacklist"] if isinstance(raw, str): diff --git a/backend/app/services/regime_builder.py b/backend/app/services/regime_builder.py index f119c03..17605ed 100644 --- a/backend/app/services/regime_builder.py +++ b/backend/app/services/regime_builder.py @@ -449,6 +449,23 @@ def run_regime_batch(repo, start: date, end: date) -> pl.DataFrame: logger.info("regime batch: no enriched data for [%s~%s]", start, end) return pl.DataFrame() + # 口径: 默认剔除风险警示(ST)股(与主线统计同一开关) — 主板 ST 在 2026-07 前 + # 享 5% 涨跌幅且是跨行业状态桶, 混入会系统性抬高涨停宽度/高度(弱市炒 ST 尤甚)。 + # 涨跌家数/MA20 占比等宽度指标几乎不受影响。切换口径需全量重算 regime。 + try: + from app.services import preferences as _prefs_st + exclude_st = _prefs_st.get_sentiment_exclude_st() + except Exception: + exclude_st = True + if exclude_st: + from app.services.market_mainline import load_risk_warning_symbols + + st_syms = load_risk_warning_symbols(repo.store.data_dir) + if st_syms and "symbol" in df.columns: + df = df.filter( + ~pl.col("symbol").str.to_uppercase().is_in(sorted(st_syms)) + ) + return _aggregate_daily(df, index_pct_map) diff --git a/backend/tests/test_market_mainline.py b/backend/tests/test_market_mainline.py index 2581d5c..c312039 100644 --- a/backend/tests/test_market_mainline.py +++ b/backend/tests/test_market_mainline.py @@ -159,4 +159,70 @@ class TestMainlineFilterPreferences: 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": []} + assert cfg == {"min_members": 4, "max_members": 600, "blacklist": [], "exclude_st": True} + + def test_sentiment_exclude_st_roundtrip(self, tmp_path, monkeypatch): + path = tmp_path / "preferences.json" + monkeypatch.setattr(preferences, "_path", lambda: path) + assert preferences.get_sentiment_exclude_st() is True # 默认剔除 + assert preferences.set_sentiment_exclude_st(False) is False + assert preferences.get_sentiment_exclude_st() is False + # 经主线过滤配置部分更新同样生效 + got = preferences.set_mainline_filter_config({"exclude_st": True}) + assert got["exclude_st"] is True + + +class TestExcludeST: + """风险警示股剔除: 维表名称含 ST → 主线聚合前过滤。""" + + @staticmethod + def _write_instruments(tmp_path, names: dict[str, str]) -> None: + part = tmp_path / "instruments" / "part.parquet" + part.parent.mkdir(parents=True, exist_ok=True) + pl.DataFrame({ + "symbol": list(names), + "name": list(names.values()), + }).write_parquet(part) + + def _reset_cache(self, monkeypatch): + monkeypatch.setattr(market_mainline, "_ST_SYMBOLS_CACHE", None) + + def test_load_risk_warning_symbols(self, tmp_path, monkeypatch): + self._reset_cache(monkeypatch) + self._write_instruments(tmp_path, { + "s1.SH": "*ST环保", "S2.SH": "ST万邦", "S3.SZ": "正常股", + "s4.BJ": "S*ST京", "S5.SH": "斯太尔", # 中文名含"斯"不含 ST 标记 + }) + got = market_mainline.load_risk_warning_symbols(tmp_path) + assert got == frozenset({"S1.SH", "S2.SH", "S4.BJ"}) # 大写归一 + # 缓存命中: 再次读取不重扫磁盘 + self._write_instruments(tmp_path, {"S9.SH": "ST新增"}) + assert market_mainline.load_risk_warning_symbols(tmp_path) == got + + def test_load_risk_warning_symbols_empty_dir(self, tmp_path, monkeypatch): + self._reset_cache(monkeypatch) + assert market_mainline.load_risk_warning_symbols(tmp_path) == frozenset() + + def test_compute_mainline_excludes_st(self, tmp_path, monkeypatch): + """S1(ST) 涨停被剔除 → 概念 X 计数/高度/龙头随之变化; 关闭开关恢复。""" + self._reset_cache(monkeypatch) + self._write_instruments(tmp_path, {"S1.SH": "*ST一", "S2.SH": "正常一"}) + repo, d1, d2 = TestComputeMainline()._setup(tmp_path, monkeypatch) + cfg = {"min_members": 4, "max_members": 600, "blacklist": []} + + out = market_mainline.compute_mainline_range( + repo, tmp_path, d1, d2, kind="concept", filter_cfg=cfg, exclude_st=True, + ) + x_d1 = out.filter((pl.col("date") == d1) & (pl.col("member") == "X")).to_dicts()[0] + assert x_d1["limit_up_count"] == 3 # S1(ST) 被剔除, 剩 S2,S3,S4 + assert x_d1["ge2_count"] == 1 # 仅 S4=2板 + assert x_d1["max_boards"] == 2 + assert x_d1["leader_symbol"] == "S4.SH" + + out_keep = market_mainline.compute_mainline_range( + repo, tmp_path, d1, d2, kind="concept", filter_cfg=cfg, exclude_st=False, + ) + x_d1_keep = out_keep.filter((pl.col("date") == d1) & (pl.col("member") == "X")).to_dicts()[0] + assert x_d1_keep["limit_up_count"] == 4 + assert x_d1_keep["ge2_count"] == 2 # S1=2板, S4=2板 + assert x_d1_keep["leader_symbol"] == "S1.SH" diff --git a/backend/tests/test_regime_builder.py b/backend/tests/test_regime_builder.py index a33027a..45641f2 100644 --- a/backend/tests/test_regime_builder.py +++ b/backend/tests/test_regime_builder.py @@ -146,6 +146,43 @@ def test_aggregate_empty_returns_empty(): assert regime_builder._aggregate_daily(pl.DataFrame()).is_empty() +def test_run_regime_batch_excludes_st(tmp_path, monkeypatch): + """run_regime_batch 默认剔除风险警示股: A(*ST) 的涨停/涨幅不进入统计。 + + 宽度类指标(涨跌家数)同样只统计非 ST 标的 — 同一 df 统一过滤。 + 关闭开关(preferences)后恢复全市场口径。 + """ + from app.services import market_mainline, preferences + + instruments = tmp_path / "instruments" / "part.parquet" + instruments.parent.mkdir(parents=True, exist_ok=True) + pl.DataFrame({"symbol": ["A", "B"], "name": ["*ST甲", "正常乙"]}).write_parquet(instruments) + monkeypatch.setattr(market_mainline, "_ST_SYMBOLS_CACHE", None) + monkeypatch.setattr(preferences, "get_sentiment_exclude_st", lambda: True) + monkeypatch.setattr(regime_builder, "_load_index_pct", lambda *a, **k: {}) + + class _FakeRepo: + class store: + data_dir = tmp_path + + def get_enriched_range(self, start, end): + return _enriched_df() + + out = regime_builder.run_regime_batch(_FakeRepo(), date(2026, 1, 2), date(2026, 1, 3)) + r1 = out.filter(pl.col("date") == date(2026, 1, 2)).row(0, named=True) + r2 = out.filter(pl.col("date") == date(2026, 1, 3)).row(0, named=True) + assert r1["limit_up"] == 0 # A(ST) 涨停被剔除 + assert r1["up_count"] == 1 # 剩 B/C/D 中仅 C 上涨 + assert r1["down_count"] == 2 + assert r2["max_consecutive"] == 1 # A 的 2板不计, C=1板 + + monkeypatch.setattr(preferences, "get_sentiment_exclude_st", lambda: False) + out_all = regime_builder.run_regime_batch(_FakeRepo(), date(2026, 1, 2), date(2026, 1, 3)) + r1_all = out_all.filter(pl.col("date") == date(2026, 1, 2)).row(0, named=True) + assert r1_all["limit_up"] == 1 # A 计入 + assert r1_all["up_count"] == 2 # A,C 上涨 + + # ───────────────────────── 持久化(upsert) ───────────────────────── diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 38e0ef9..a652ede 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -582,6 +582,7 @@ export interface MainlineFilter { min_members: number max_members: number blacklist: string[] + exclude_st: boolean } export interface MainlineResult { @@ -2072,7 +2073,7 @@ export const api = { }, regimeMainlineRecompute: () => request<{ ok: boolean; rows: number }>('/api/regime/mainline/recompute', { method: 'POST' }), - mainlineFilterUpdate: (payload: { min_members?: number; max_members?: number; blacklist?: string[] }) => + mainlineFilterUpdate: (payload: { min_members?: number; max_members?: number; blacklist?: string[]; exclude_st?: boolean }) => request('/api/settings/preferences/mainline-filter', { method: 'PUT', body: JSON.stringify(payload), diff --git a/frontend/src/pages/Regime.tsx b/frontend/src/pages/Regime.tsx index a77cc81..4daf3e7 100644 --- a/frontend/src/pages/Regime.tsx +++ b/frontend/src/pages/Regime.tsx @@ -1138,13 +1138,15 @@ export function Regime() { // ── 主线过滤设置面板 ────────────────────────────────────── // 宽基/风格标签(融资融券/沪深股通等数千成分)会霸占主线榜首。默认按成员数 // 上限过滤; 用户可调阈值并按名称屏蔽特定概念, 保存后自动重算主线。 +// ST 剔除开关联动情绪周期口径 — 切换时额外触发 regime 全量重算。 function MainlineFilterPanel({ filter, onDone }: { - filter: { min_members: number; max_members: number; blacklist: string[] } | undefined + filter: { min_members: number; max_members: number; blacklist: string[]; exclude_st?: boolean } | undefined onDone: () => Promise }) { const [minMembers, setMinMembers] = useState(String(filter?.min_members ?? 4)) const [maxMembers, setMaxMembers] = useState(String(filter?.max_members ?? 600)) const [blacklist, setBlacklist] = useState(filter?.blacklist ?? []) + const [excludeSt, setExcludeSt] = useState(filter?.exclude_st ?? true) const [input, setInput] = useState('') const [saving, setSaving] = useState(false) @@ -1161,9 +1163,17 @@ function MainlineFilterPanel({ filter, onDone }: { min_members: Math.max(1, Number(minMembers) || 4), max_members: Math.max(50, Number(maxMembers) || 600), blacklist, + exclude_st: excludeSt, }) - await api.regimeMainlineRecompute() - toast('过滤已保存, 主线已重算', 'success') + const stChanged = excludeSt !== (filter?.exclude_st ?? true) + if (stChanged) { + // 口径切换影响情绪周期驱动指标, 需全量重算 regime+主线(较重, 需等待) + await api.regimeRecompute() + toast('过滤已保存, 主线与情绪周期已全量重算', 'success') + } else { + await api.regimeMainlineRecompute() + toast('过滤已保存, 主线已重算', 'success') + } await onDone() } catch (e) { toast(`保存失败 · ${String((e as Error)?.message || e)}`, 'error') @@ -1213,8 +1223,31 @@ function MainlineFilterPanel({ filter, onDone }: { {saving ? : '保存并重算'} +
+ + + 统计剔除 ST 股 + 主线 + 情绪周期统一口径; 切换后自动全量重算(约 1-2 分钟) + +
- 说明: 成分股数超过上限的概念(如 融资融券~7700家/沪深股通~3300家)视为宽基/风格标签, 不参与主线排名; 修改后自动重算全部历史主线(秒级)。 + 说明: 成分股数超过上限的概念(如 融资融券~7700家/沪深股通~3300家)视为宽基/风格标签, 不参与主线排名; + 风险警示股(名称含 ST)不参与涨停梯队统计 — 主板 5% 便宜板时代曾系统性霸榜, 且 ST 是状态桶而非题材。修改后自动重算全部历史(秒级~分钟级)。
) diff --git a/操作说明书.md b/操作说明书.md index a9778a1..728c152 100644 --- a/操作说明书.md +++ b/操作说明书.md @@ -1191,7 +1191,14 @@ git clean -fdx - 历史判断应使用对应交易日规则,而不是当前名称直接覆盖历史状态。 - 实时信号应优先使用合法涨跌停价与实时价格比较,并处理价格精度。 -### 24.11 飞书或企业微信没有收到通知 +### 24.11 主线排行或情绪周期里没有 ST 板块 + +- 市场环境页的「主线过滤」默认开启"统计剔除 ST 股":名称含 ST 的风险警示股不参与涨停梯队与主线统计,情绪周期驱动指标同口径。 +- 原因:ST 是跨行业的状态标签而非投资题材,且主板 ST 在 2026-07 前涨跌幅为 5%,封板成本减半,混入会系统性抬高涨停宽度与高度。 +- 如需恢复全市场口径,在「市场环境 → 情绪周期 → 主线排行 → 过滤」中关闭开关并保存(会自动全量重算,约 1-2 分钟)。 +- 修改开关或维表名称快照变化后,建议点「重算」使历史与新口径一致。 + +### 24.12 飞书或企业微信没有收到通知 - 测试 Webhook 是否有效。 - 检查监控规则是否启用且产生触发记录。 @@ -1199,14 +1206,14 @@ git clean -fdx - 检查服务端网络能否访问通知地址。 - 查看后端日志中的发送状态。 -### 24.12 前端仍是旧界面 +### 24.13 前端仍是旧界面 1. 打开“设置 → 系统设置”。 2. 点击“刷新前端缓存”。 3. 重新加载页面。 4. Docker 部署确认镜像已重新构建。 -### 24.13 删除策略失败或返回 500 +### 24.14 删除策略失败或返回 500 - 确认删除的是用户策略,不是内置策略。 - 检查 `data/strategies/` 挂载目录是否可写。 @@ -1215,11 +1222,11 @@ git clean -fdx 当前删除逻辑支持从页面删除用户策略文件,不要求用户进入 Docker 容器操作。 -### 24.14 数据删除提示回收站不可用 +### 24.15 数据删除提示回收站不可用 Windows 沙箱或容器可能无法使用系统回收站。当前版本会在目标通过安全路径校验后使用受控直接删除。若仍失败,检查数据目录权限和挂载状态,不要手工扩大删除范围。 -### 24.15 查看日志 +### 24.16 查看日志 开发模式:查看运行 `dev.sh` 或 `dev.ps1` 的前后端终端。