feat(mainline): 主线与情绪周期统计默认剔除风险警示(ST)股

问题: ST板块 241 天中 209 天上主线榜、108 天第一。原因: ① ST 是跨行业
状态桶(416只)非投资题材, 真实主线被概念拆分而 ST 统一聚桶; ② 主板 ST
2026-07-06 前涨跌幅 5%(价格规则已内置切换日), 封板成本减半, 弱市刷板
(7/1 全市场 218 家涨停中 67 家 ST); ③ 概念成分/ST 标记按当前维表快照
回写历史存在归属污染。

改动:
- market_mainline: load_risk_warning_symbols(维表名称含 ST, 与涨跌停
  判定共用 price_limits 权威实现, 600s 缓存); compute_mainline_range
  聚合前剔除, exclude_st 参数可覆盖
- regime_builder: run_regime_batch 汇聚点统一剔除(情绪驱动与主线同
  口径); 宽度类指标同 df 过滤, 占比几乎不受影响
- preferences: sentiment_exclude_st 默认 True; 经 mainline-filter
  API 可读写; 前端过滤面板加开关, 切换时自动全量重算
- 测试: 符号集加载/缓存、主线剔除(计数/龙头/最小涨停门槛交互)、
  regime 剔除 A/B、偏好往返; 说明书 FAQ 24.11

验证: 后端 918 测试通过(新增 5 个); tsc 通过; 全量重算后主线 0 行
ST 上榜(此前 209 天), rank1 变为海工装备/卫星导航/液冷服务器等真实
主题; 7/1 涨停数 218→151 与 ST 占比精确吻合; A/B 对照: 阶段标签
34/242 天变化(rally/ignite→repair 为主, 方向符合预期 — 剔除 ST 灌水
后正面阶段更难触发), repair 主导(78%)为口径切换前既有特征。
This commit is contained in:
shy3130
2026-08-17 16:49:28 +08:00
parent 7c30f5bcac
commit 65f46a1e7f
9 changed files with 243 additions and 14 deletions
+6 -2
View File
@@ -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()
+47 -1
View File
@@ -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()
+18
View File
@@ -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):
+17
View File
@@ -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)
+67 -1
View File
@@ -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"
+37
View File
@@ -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) ─────────────────────────
+2 -1
View File
@@ -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<MainlineFilter>('/api/settings/preferences/mainline-filter', {
method: 'PUT',
body: JSON.stringify(payload),
+37 -4
View File
@@ -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<void>
}) {
const [minMembers, setMinMembers] = useState(String(filter?.min_members ?? 4))
const [maxMembers, setMaxMembers] = useState(String(filter?.max_members ?? 600))
const [blacklist, setBlacklist] = useState<string[]>(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 ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : '保存并重算'}
</button>
</div>
<div className="mt-2 flex items-center gap-2 border-t border-border/60 pt-2">
<button
type="button"
role="switch"
aria-checked={excludeSt}
aria-label="统计剔除 ST 股"
onClick={() => setExcludeSt(v => !v)}
className={cn(
'relative inline-flex h-4.5 w-8 items-center rounded-full border transition-all duration-200',
excludeSt ? 'border-accent/50 bg-accent' : 'border-border bg-elevated hover:border-muted',
)}
>
<span className={cn(
'inline-block h-3 w-3 rounded-full border border-black/5 bg-white shadow-sm transition-transform duration-200',
excludeSt ? 'translate-x-[17px]' : 'translate-x-0.5',
)} />
</button>
<span className="text-[11px] text-secondary">
ST
<span className="ml-1.5 text-[9px] text-muted">线 + ; ( 1-2 )</span>
</span>
</div>
<div className="mt-1.5 text-[9px] text-muted">
说明: 成分股数超过上限的概念( ~7700/~3300)/, 线; 线()
说明: 成分股数超过上限的概念( ~7700/~3300)/, 线;
( ST) 5% 便, ST (~)
</div>
</div>
)
+12 -5
View File
@@ -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` 的前后端终端。