异动监控修复与规则口径对齐 (上交所交易规则 2026 修订)

- 盘中增量路径补算偏离列: compute_enriched_today 补 momentum_3d,
  live_agg 新增 _close_3d_ago 递推状态, 增量/全量回退两路径统一附着
  今日偏离 (基准 = 历史帧昨收 × (1+指数实时涨跌) 外推, 排除盘中
  写入的今日指数行), 修复盘中异动列表恒为空的问题
- 主板 ST 口径统一: 2026-07-06 起风险警示股票涨跌幅 10% 且异常波动
  特别规定废止, 删除原 ±15%/10日+50%/30日+100% 从严表, ST 与普通
  主板同标准
- 严重异常波动负向阈值对齐官方不对称口径: 10日 +100%(-50%),
  30日 +200%(-70%), 跌方向更早触发; 前端规则表/窗口徽标同步双侧显示
This commit is contained in:
shy3130
2026-08-21 17:41:35 +08:00
parent 68ce23370a
commit 21260f1053
7 changed files with 339 additions and 49 deletions
+127 -12
View File
@@ -1059,7 +1059,8 @@ _BENCHMARK_CACHE_TTL = 600.0
def load_benchmark_momentum(data_dir: Path) -> pl.DataFrame | None:
"""读取指数日K, 计算各基准指数的滚动 N 日涨跌幅。
返回长表: date, bench_exchange, bench_mom3d, bench_mom10d, bench_mom30d。
返回长表: date, bench_exchange, bench_close, bench_mom3d, bench_mom10d, bench_mom30d。
bench_close 供盘中路径外推今日基准动量 (benchmark_momentum_today)。
无可用指数数据时返回 None (偏离列置 null, 不阻塞主流程)。
进程内按 data_dir 缓存 (TTL 10 分钟)。
"""
@@ -1116,7 +1117,9 @@ def load_benchmark_momentum(data_dir: Path) -> pl.DataFrame | None:
})
frame = (
df_bench.join(exchange_map, on="symbol", how="inner")
.select(["date", "bench_exchange", *[f"bench_mom{n}d" for n in DEVIATION_WINDOWS]])
.select(["date", "bench_exchange", "close",
*[f"bench_mom{n}d" for n in DEVIATION_WINDOWS]])
.rename({"close": "bench_close"})
.unique(subset=["date", "bench_exchange"])
)
except Exception as exc: # noqa: BLE001
@@ -1127,8 +1130,19 @@ def load_benchmark_momentum(data_dir: Path) -> pl.DataFrame | None:
return frame
def _bench_exchange_expr() -> pl.Expr:
"""symbol 后缀 → 交易所 (SH/SZ/BJ), 无法识别时 null。"""
return (
pl.col("symbol").str.slice(-2).str.to_uppercase().replace(
{ex: ex for ex in _BENCHMARK_PREFERENCE},
default=None,
return_dtype=pl.Utf8,
)
)
def attach_deviation_columns(df: pl.DataFrame, data_dir: Path) -> pl.DataFrame:
"""为已含 momentum_Nd 的 enriched 帧附着 deviate_Nd 偏离列。
"""为已含 momentum_Nd 的 enriched 帧附着 deviate_Nd 偏离列 (全量/冷路径)
缺失的动量列 (如 momentum_3d 不在指标全集里) 就地按 close 补算,
与 compute_indicators 在同一帧上的 shift 语义一致。
@@ -1149,25 +1163,120 @@ def attach_deviation_columns(df: pl.DataFrame, data_dir: Path) -> pl.DataFrame:
(pl.col("close") / pl.col("close").shift(n).over("symbol") - 1).alias(f"momentum_{n}d")
for n in missing
])
bench_exchange = (
pl.col("symbol").str.slice(-2).str.to_uppercase().replace(
{ex: ex for ex in _BENCHMARK_PREFERENCE},
default=None,
return_dtype=pl.Utf8,
)
)
out = (
df.with_columns(bench_exchange.alias("_bench_ex"))
df.with_columns(_bench_exchange_expr().alias("_bench_ex"))
.join(bench, left_on=["_bench_ex", "date"], right_on=["bench_exchange", "date"], how="left")
.with_columns([
(pl.col(f"momentum_{n}d") - pl.col(f"bench_mom{n}d")).alias(f"deviate_{n}d")
for n in DEVIATION_WINDOWS
])
.drop(["_bench_ex", *[f"bench_mom{n}d" for n in DEVIATION_WINDOWS]])
.drop(["_bench_ex", "bench_close", *[f"bench_mom{n}d" for n in DEVIATION_WINDOWS]])
)
return out
def _bench_rt_pct_of(index_quotes: pl.DataFrame | None, candidates: list[str]) -> float:
"""从实时指数行情取某交易所首选基准的今日涨跌, 缺数据时 0。"""
if index_quotes is None or index_quotes.is_empty():
return 0.0
df = index_quotes.filter(pl.col("symbol").is_in(candidates))
if df.is_empty():
return 0.0
# 候选按优先级排序, 取第一个有有效涨跌的
by_sym = {r["symbol"]: r for r in df.iter_rows(named=True)}
for sym in candidates:
row = by_sym.get(sym)
if row is None:
continue
for col in ("change_pct", "pct", "pct_change"):
v = row.get(col)
if v is not None:
return float(v)
if row.get("close") is not None and row.get("prev_close") is not None and row["prev_close"]:
return float(row["close"] / row["prev_close"] - 1)
return 0.0
def benchmark_momentum_today(
data_dir: Path,
index_quotes: pl.DataFrame | None = None,
) -> pl.DataFrame | None:
"""各交易所基准指数的「今日」N 日动量 (盘中实时外推)。
基准日K parquet 盘中不含今日, 今日基准收盘 = 昨收 × (1 + 实时涨跌)。
N 日动量 = 今日基准收盘 / N 个交易日前的收盘 - 1; 交易所与
load_benchmark_momentum 的选基逻辑一致 (同一 TTL 缓存帧)。
返回小表: bench_exchange, bench_mom3d, bench_mom10d, bench_mom30d。
无基准数据时 None。
"""
bench = load_benchmark_momentum(data_dir)
if bench is None or bench.is_empty():
return None
# 指数监控 (mode=all) 盘中会向 kline_index_daily 写入今日行;
# 「昨收」必须排除今日, 否则实时涨跌被重复叠加
today = cn_today()
bench = bench.filter(pl.col("date") < today)
if bench.is_empty():
return None
rows: list[dict[str, float | str]] = []
for ex in sorted(bench["bench_exchange"].unique().to_list()):
sub = bench.filter(pl.col("bench_exchange") == ex).sort("date")
closes = sub["bench_close"]
if closes.len() == 0:
continue
yesterday_close = closes[-1]
rt = _bench_rt_pct_of(index_quotes, _BENCHMARK_PREFERENCE.get(ex, []))
row: dict[str, float | str] = {
"bench_exchange": ex,
}
for n in DEVIATION_WINDOWS:
base = closes[-n] if closes.len() >= n else None # N 个交易日前 (不含今日)
row[f"bench_mom{n}d"] = (
(yesterday_close * (1.0 + rt)) / base - 1.0
if base is not None and yesterday_close is not None and base > 0
else None
)
rows.append(row)
if not rows:
return None
schema = {"bench_exchange": pl.Utf8, **{f"bench_mom{n}d": pl.Float64 for n in DEVIATION_WINDOWS}}
return pl.DataFrame(rows, schema=schema)
def attach_deviation_columns_today(
df: pl.DataFrame,
data_dir: Path,
index_quotes: pl.DataFrame | None = None,
) -> pl.DataFrame:
"""为盘中单日 enriched 帧附着 deviate_Nd 偏离列 (增量热路径)。
与 attach_deviation_columns 的区别: 入参是「仅今日」的单日帧, 无法用
shift 补算动量, 直接使用帧上已有的 momentum_Nd (compute_enriched_today
产出); 基准动量用 benchmark_momentum_today 的实时外推值。
缺失动量的窗口 (如全量回退路径无 momentum_3d) 置 null, 不阻塞主流程。
"""
dev_cols = [f"deviate_{n}d" for n in DEVIATION_WINDOWS]
if df.is_empty():
return df
bench = benchmark_momentum_today(data_dir, index_quotes)
if bench is None or bench.is_empty():
return df.with_columns([
pl.lit(None, dtype=pl.Float64).alias(c) for c in dev_cols if c not in df.columns
])
exprs = [
(pl.col(f"momentum_{n}d") - pl.col(f"bench_mom{n}d")).alias(f"deviate_{n}d")
if f"momentum_{n}d" in df.columns
else pl.lit(None, dtype=pl.Float64).alias(f"deviate_{n}d")
for n in DEVIATION_WINDOWS
]
return (
df.with_columns(_bench_exchange_expr().alias("_bench_ex"))
.join(bench, left_on="_bench_ex", right_on="bench_exchange", how="left")
.with_columns(exprs)
.drop(["_bench_ex", *[f"bench_mom{n}d" for n in DEVIATION_WINDOWS]])
)
def run_pipeline(data_dir: Path | None = None,
symbols: list[str] | None = None,
new_dates_only: bool = False,
@@ -1732,6 +1841,12 @@ def compute_enriched_today(
(pl.col("close") / pl.col("_close_60d_ago") - 1).alias("momentum_60d"),
])
# ---- 动量 3d (异动偏离 deviate_3d 用; 旧 live_agg 未带该状态时跳过, 偏离列自然置 null) ----
if "_close_3d_ago" in df.columns:
df = df.with_columns(
(pl.col("close") / pl.col("_close_3d_ago") - 1).alias("momentum_3d")
)
# ---- 年化波动率 20d (递推) ----
# 用 Welford 简化: sum + sum_sq of 19 historical returns + today's return
today_ret = pl.col("close") / pl.col("prev_close") - 1
+27 -19
View File
@@ -1,13 +1,19 @@
"""异动边缘统计 — 按交易所异动规则口径实时计算个股接近度。
规则 (近似口径, 与交易所《交易规则》的异常波动/严重异常波动披露阈值对齐):
- 主板: 连续3日收盘价涨跌幅偏离值累计 ±20% (风险警示 ±15%)
- 创业板/科创板: 3日 ±30%
规则 (近似口径, 与交易所《交易规则》的异常波动/严重异常波动披露阈值对齐;
主板/科创板条款号指上交所《交易规则(2026年修订)》, 2026-07-06 施行):
- 主板: 连续3日收盘价涨跌幅偏离值累计 ±20% (5.4.2)
- 创业板/科创板: 3日 ±30% (科创板 6.10)
- 北交所: 3日 ±40%
- 严重异常波动: 10日累计偏离 +100% (风险警示 +50%), 30日 +200% (风险警示 +100%)
- 严重异常波动 (5.4.3/6.11): 10日累计偏离 +100%(-50%), 30日 +200%(-70%)
负向阈值显著严于正向 (跌方向更早触发), 各板块相同。
「10日内4次同向异常波动」情形 (科创板3次) 需事件计数, 暂未实现。
- 风险警示 (ST/*ST): 2026-07-06 起主板风险警示股票涨跌幅限制调整为 10%,
异常波动特别规定 (原 3日±15% / 10日+50% / 30日+100%) 同步废止,
与主板普通股票适用同一套标准 (见 price_limits.MAIN_BOARD_ST_LIMIT_CHANGE_DATE)。
偏离值 = 个股 N 日累计涨跌幅 - 对应指数同期涨跌幅 (enriched 运行时列 deviate_Nd)。
「接近度」= |实时偏离| / 阈值: ≥1 已触发, ≥0.7 边缘, ≥0.5 观察。
「接近度」= |实时偏离| / 该方向阈值: ≥1 已触发, ≥0.7 边缘, ≥0.5 观察。
盘中实时叠加: 历史偏离 (已完成交易日) + 今日实时涨跌 - 基准指数今日涨跌。
"""
@@ -29,23 +35,22 @@ from app.indicators.pipeline import DEVIATION_WINDOWS
class AbnormalRule:
board: str
st: bool
# 各窗口阈值 (小数): {3: 0.20, 10: 1.00, 30: 2.00}
thresholds: dict[int, float]
# 各窗口阈值 (小数): {窗口: (正向, 负向)} — 严重异动负向阈值更严 (见模块 docstring)
thresholds: dict[int, tuple[float, float]]
_MAIN = {3: 0.20, 10: 1.00, 30: 2.00}
_MAIN_ST = {3: 0.15, 10: 0.50, 30: 1.00}
_GEM_STAR = {3: 0.30, 10: 1.00, 30: 2.00}
_BSE = {3: 0.40, 10: 1.00, 30: 2.00}
# 3日异常波动阈值各板块对称; 10/30日严重异动各板块一致且不对称 (+100%/-50%, +200%/-70%)
_MAIN = {3: (0.20, 0.20), 10: (1.00, 0.50), 30: (2.00, 0.70)}
_GEM_STAR = {3: (0.30, 0.30), 10: (1.00, 0.50), 30: (2.00, 0.70)}
_BSE = {3: (0.40, 0.40), 10: (1.00, 0.50), 30: (2.00, 0.70)}
RULES_META: list[dict[str, Any]] = [
{"board": "主板", "st": False, "thresholds": {f"{k}d": v for k, v in _MAIN.items()},
"note": "3日±20% 异常波动; 10日+100%/30日+200% 严重异常波动"},
{"board": "主板", "st": True, "thresholds": {f"{k}d": v for k, v in _MAIN_ST.items()},
"note": "风险警示股票 (ST/*ST) 阈值从严"},
{"board": "创业板/科创板", "st": False, "thresholds": {f"{k}d": v for k, v in _GEM_STAR.items()},
{"board": "主板", "st": False, "thresholds": {f"{k}d": {"up": u, "down": d} for k, (u, d) in _MAIN.items()},
"note": "3日±20% 异常波动; 严重异常波动 10日+100%(-50%) / 30日+200%(-70%), "
"负向更严; 2026-07-06 起风险警示(ST)股票同口径 (原±15%特别规定已废止)"},
{"board": "创业板/科创板", "st": False, "thresholds": {f"{k}d": {"up": u, "down": d} for k, (u, d) in _GEM_STAR.items()},
"note": "20%涨跌幅板块, 3日±30%"},
{"board": "北交所", "st": False, "thresholds": {f"{k}d": v for k, v in _BSE.items()},
{"board": "北交所", "st": False, "thresholds": {f"{k}d": {"up": u, "down": d} for k, (u, d) in _BSE.items()},
"note": "30%涨跌幅板块, 3日±40%"},
]
@@ -71,11 +76,13 @@ def is_st_name(name: str | None) -> bool:
def rule_for(symbol: str, name: str | None) -> AbnormalRule:
board = board_of(symbol)
st = is_st_name(name)
# 主板风险警示股票 2026-07-06 起与普通股票同标准 (涨跌幅 10%,
# 异常波动特别规定废止); st 仅为展示标记。创业板/科创板/北交所本就不区分。
if board == "北交所":
return AbnormalRule(board, st, _BSE)
if board in ("创业板", "科创板"):
return AbnormalRule(board, st, _GEM_STAR)
return AbnormalRule(board, st, _MAIN_ST if st else _MAIN)
return AbnormalRule(board, st, _MAIN)
# ── 快照计算 ──────────────────────────────────────────────
@@ -178,7 +185,8 @@ def build_overview(
if hist_dev is None:
continue
live = hist_dev + rt_delta
threshold = rule.thresholds[n]
up_t, down_t = rule.thresholds[n]
threshold = up_t if live >= 0 else down_t
closeness = abs(live) / threshold if threshold > 0 else 0.0
windows[f"{n}d"] = {
"value": round(live, 4),
+16
View File
@@ -1593,11 +1593,27 @@ class QuoteService:
else None
),
)
# momentum_3d 不在指标全集里, 但 deviate_3d 需要; 多日帧上 shift 补算
enriched_full = enriched_full.sort(["symbol", "date"]).with_columns(
(pl.col("close") / pl.col("close").shift(3).over("symbol") - 1).alias("momentum_3d")
)
enriched_today = enriched_full.filter(pl.col("date") == today)
if enriched_today.is_empty():
return
# 异动偏离列: 盘中路径不经过 _refresh_enriched 冷刷新,
# 需在此附着 (基准 = 历史帧 + 指数实时外推), 否则盘中异动列表为空
if asset_type == "stock":
from app.indicators.pipeline import attach_deviation_columns_today
try:
index_quotes = self.get_index_quotes()
except Exception:
index_quotes = None
enriched_today = attach_deviation_columns_today(
enriched_today, self._repo.store.data_dir, index_quotes
)
# ---- 写盘 + 更新缓存 ----
if merge:
self._repo.merge_live_enriched_asset(asset_type, enriched_today)
+2
View File
@@ -905,6 +905,8 @@ class KlineRepository:
pl.col("high").tail(59).max().alias("_high_59d"),
pl.col("low").tail(59).min().alias("_low_59d"),
# 异动偏离 deviate_3d 用 (与 5d/10d/30d 同语义: 尾部第 N 个收盘)
pl.col("close").tail(3).first().alias("_close_3d_ago"),
pl.col("close").tail(5).first().alias("_close_5d_ago"),
pl.col("close").tail(10).first().alias("_close_10d_ago"),
pl.col("close").tail(20).first().alias("_close_20d_ago"),
+146 -6
View File
@@ -1,11 +1,16 @@
"""异动边缘统计测试 — 偏离列附着 + 规则口径 + 快照接近度。"""
from __future__ import annotations
from datetime import date
from datetime import date, timedelta
import polars as pl
from app.indicators.pipeline import attach_deviation_columns, load_benchmark_momentum
from app.indicators.pipeline import (
attach_deviation_columns,
attach_deviation_columns_today,
benchmark_momentum_today,
load_benchmark_momentum,
)
from app.services.abnormal_moves import (
_hist_cache,
_hist_cache_lock,
@@ -69,6 +74,99 @@ def test_attach_deviation_columns_missing_benchmark(tmp_path) -> None:
assert out["deviate_3d"][0] is None
# ── 盘中路径: 今日基准动量外推 + 单日帧偏离附着 ──────────────────
_BENCH_DAYS = [date(2026, 8, 11), date(2026, 8, 12), date(2026, 8, 13),
date(2026, 8, 14), date(2026, 8, 15), date(2026, 8, 18)]
def _write_sh_bench(tmp_path) -> None:
# 上证指数 6 日收盘 10..15, 末值 15 为昨收
_write_index_daily(tmp_path, [("000001.SH", d, 10.0 + i) for i, d in enumerate(_BENCH_DAYS)])
def test_benchmark_momentum_today_math(tmp_path) -> None:
_write_sh_bench(tmp_path)
quotes = pl.DataFrame({"symbol": ["000001.SH"], "change_pct": [0.10]})
out = benchmark_momentum_today(tmp_path, quotes)
row = out.row(0, named=True)
# 今收 = 15 x 1.10 = 16.5; 3 个交易日前的收盘 = 13 (与全量路径 shift(3) 同口径)
# mom3d = 16.5/13 - 1
assert abs(row["bench_mom3d"] - (16.5 / 13 - 1)) < 1e-9
# 10/30 日窗口收盘数不足 → null
assert row["bench_mom10d"] is None
assert row["bench_mom30d"] is None
# 无实时行情 → rt 按 0 处理: mom3d = 15/13 - 1
out0 = benchmark_momentum_today(tmp_path, None)
assert abs(out0.row(0, named=True)["bench_mom3d"] - (15.0 / 13 - 1)) < 1e-9
def test_benchmark_momentum_today_excludes_today_rows(tmp_path) -> None:
# 指数监控盘写入的今日行不能当昨收 (否则实时涨跌被重复叠加)
today = date.today()
rows = [("000001.SH", d, 10.0 + i) for i, d in enumerate(_BENCH_DAYS)]
rows.append(("000001.SH", today, 99.0)) # 今日脏行
_write_index_daily(tmp_path, rows)
out = benchmark_momentum_today(tmp_path, None)
assert abs(out.row(0, named=True)["bench_mom3d"] - (15.0 / 13 - 1)) < 1e-9
def test_attach_deviation_columns_today(tmp_path) -> None:
_write_sh_bench(tmp_path)
quotes = pl.DataFrame({"symbol": ["000001.SH"], "change_pct": [0.10]})
# 单日帧: 增量路径产出的 momentum 列 (无 date 历史, 无法 shift 补算)
today_df = pl.DataFrame(
{
"symbol": ["600000.SH", "000001.SZ"],
"momentum_3d": [0.5, 0.2],
"momentum_10d": [0.2, None],
"momentum_30d": [1.0, None],
}
)
out = attach_deviation_columns_today(today_df, tmp_path, quotes)
# SH: 0.5 - (16.5/13 - 1)
assert abs(out["deviate_3d"][0] - (0.5 - (16.5 / 13 - 1))) < 1e-9
# SZ 无深证基准 → 按选基设计回退上证基准 (rt=0): 0.2 - (15/13 - 1)
assert abs(out["deviate_3d"][1] - (0.2 - (15.0 / 13 - 1))) < 1e-9
assert "bench_close" not in out.columns
def test_attach_deviation_columns_today_missing_momentum(tmp_path) -> None:
# 全量回退路径可能缺 momentum_3d: 该窗口置 null, 其余窗口正常
days = [date(2026, 7, 1) + timedelta(days=i) for i in range(35)]
_write_index_daily(tmp_path, [("000001.SH", d, 10.0 + i) for i, d in enumerate(days)])
df = pl.DataFrame(
{
"symbol": ["600000.SH"],
"momentum_10d": [0.2],
"momentum_30d": [1.0],
}
)
out = attach_deviation_columns_today(df, tmp_path, None)
assert out["deviate_3d"][0] is None
assert out["deviate_10d"][0] is not None
assert out["deviate_30d"][0] is not None
def test_attach_deviation_columns_no_bench_close_leak(tmp_path) -> None:
# load_benchmark_momentum 新增 bench_close 列后, 冷路径输出不应泄漏该列
_write_sh_bench(tmp_path)
stock = pl.DataFrame(
{
"symbol": ["600000.SH"],
"date": [date(2026, 8, 18)],
"close": [15.0],
}
)
out = attach_deviation_columns(stock, tmp_path)
assert "bench_close" not in out.columns
frame = load_benchmark_momentum(tmp_path)
assert "bench_close" in frame.columns
def test_board_and_st_rules() -> None:
assert board_of("600000.SH") == "主板"
assert board_of("000001.SZ") == "主板"
@@ -79,13 +177,17 @@ def test_board_and_st_rules() -> None:
assert is_st_name("正常股") is False
main = rule_for("600000.SH", "正常股")
assert main.thresholds == {3: 0.20, 10: 1.00, 30: 2.00}
# 3日对称 ±20%; 严重异动负向更严: 10日+100%(-50%), 30日+200%(-70%)
assert main.thresholds == {3: (0.20, 0.20), 10: (1.00, 0.50), 30: (2.00, 0.70)}
# 2026-07-06 起主板风险警示股票与普通股票同标准 (原±15%特别规定已废止)
st = rule_for("600000.SH", "ST 某某")
assert st.thresholds == {3: 0.15, 10: 0.50, 30: 1.00}
assert st.thresholds == main.thresholds
assert st.st is True
gem = rule_for("301123.SZ", "正常股")
assert gem.thresholds[3] == 0.30
assert gem.thresholds[3] == (0.30, 0.30)
assert gem.thresholds[10] == (1.00, 0.50)
bse = rule_for("920001.BJ", "正常股")
assert bse.thresholds[3] == 0.40
assert bse.thresholds[3] == (0.40, 0.40)
class _FakeRepo:
@@ -161,6 +263,44 @@ def test_build_overview_cache_date_today_no_double_count() -> None:
assert abs(row["windows"]["3d"]["value"] - 0.19) < 1e-9
def test_build_overview_negative_side_stricter_threshold() -> None:
"""严重异动负向阈值更严 (10日-50%/30日-70%), 跌方向更早触发。"""
with _hist_cache_lock:
_hist_cache.clear()
class _TodayRepo(_FakeRepo):
def get_enriched_latest(self):
return self._df, date.today()
df = pl.DataFrame(
{
"symbol": ["600000.SH", "600001.SH"],
"name": ["跌一", "跌二"],
"close": [10.0, 20.0],
"change_pct": [-0.05, -0.05],
# -0.55: 旧对称口径 0.55/1.00=0.55 (观察); 新口径 0.55/0.50=1.1 (触发)
# -0.75: 30日 0.75/0.70≈1.07 (触发)
"deviate_3d": [None, None],
"deviate_10d": [-0.55, None],
"deviate_30d": [None, -0.75],
}
)
result = build_overview(_TodayRepo(df), None, min_closeness=0.5)
by_symbol = {r["symbol"]: r for r in result["rows"]}
a = by_symbol["600000.SH"]
assert a["windows"]["10d"]["threshold"] == 0.50
assert abs(a["windows"]["10d"]["closeness"] - 1.1) < 1e-9
assert a["status"] == "triggered"
b = by_symbol["600001.SH"]
assert b["windows"]["30d"]["threshold"] == 0.70
assert abs(b["windows"]["30d"]["closeness"] - round(0.75 / 0.7, 4)) < 1e-9
assert b["status"] == "triggered"
# 正向阈值不变: +100%/+200% (在正偏离用例中覆盖, 这里验证规则表)
main = rule_for("600000.SH", "正常股")
assert main.thresholds[10] == (1.00, 0.50)
assert main.thresholds[30] == (2.00, 0.70)
# ── 监控规则接入 (type=abnormal) ────────────────────────
import pytest
+3 -2
View File
@@ -738,7 +738,7 @@ export interface SectorMonitorTarget {
export interface AbnormalWindowInfo {
/** 实时偏离值 (小数) */
value: number
/** 该窗口阈值 (小数) */
/** 该窗口阈值 (小数) — 后端已按偏离方向取对应侧 (严重异动负向更严) */
threshold: number
/** 接近度 |value|/threshold */
closeness: number
@@ -766,7 +766,8 @@ export interface AbnormalOverview {
rules: Array<{
board: string
st: boolean
thresholds: Record<string, number>
/** 各窗口双侧阈值 {up: 正向, down: 负向} (小数) */
thresholds: Record<string, { up: number; down: number }>
note: string
}>
counts: { triggered: number; edge: number; watch: number }
+18 -10
View File
@@ -194,7 +194,7 @@ export function AbnormalMoves() {
口径说明: 偏离值 = N (: 上证A指/,
: 深证A指/, : 北证50), ,
3/10/30 (|| ÷ ,
ST ), ,
; 2026-07-06 ), ,
; 100% 70% 50%
/,
</p>
@@ -388,7 +388,12 @@ export function AbnormalMoves() {
function ruleChips() {
return (view?.rules ?? FALLBACK_RULES).map((rule, i) => {
const thr = WINDOW_KEYS.map(w => `${w.replace('d', '日')}±${fmtThreshold(rule.thresholds[w])}`).join(' / ')
// 对称窗口 (3日) 显示 ±X%; 严重异动窗口正负阈值不同, 显示 +X%/−Y%
const thr = WINDOW_KEYS.map(w => {
const t = rule.thresholds[w]
const s = t.up === t.down ? `±${fmtThreshold(t.up)}` : `+${fmtThreshold(t.up)}/${fmtThreshold(t.down)}`
return `${w.replace('d', '日')}${s}`
}).join(' / ')
return (
<div key={i} className="rounded border border-border bg-base px-2.5 py-2">
<div className="text-[11px] font-medium text-foreground">
@@ -465,15 +470,17 @@ function AbnormalRowView({ row, rank, onPreview }: {
const info = row.windows[w]
// 接近度取最高档: 来源窗口颜色加重 (加粗), 其余窗口淡化, 以此区分「哪一档」
const isDominant = dominant?.key === w
// 后端 threshold 已按偏离方向取对应侧 (严重异动负向阈值更严)
const sign = info && info.value >= 0 ? '+' : ''
return (
<td key={w} className="px-2 py-1.5 text-right">
{info ? (
<span
className={`font-mono text-xs tabular-nums ${priceColorClass(info.value)} ${isDominant ? 'font-semibold' : 'opacity-45'}`}
title={`阈值 ±${fmtThreshold(info.threshold)} · 接近度 ${(info.closeness * 100).toFixed(0)}%${isDominant ? ' · 本行接近度来源' : ''}`}
title={`阈值 ${sign}${fmtThreshold(info.threshold)} · 接近度 ${(info.closeness * 100).toFixed(0)}%${isDominant ? ' · 本行接近度来源' : ''}`}
>
{fmtPct(info.value)}
<span className="ml-1 text-[9px] text-muted/60">/{fmtThreshold(info.threshold)}</span>
<span className="ml-1 text-[9px] text-muted/60">/{sign}{fmtThreshold(info.threshold)}</span>
</span>
) : (
<span className="text-muted/40"></span>
@@ -543,10 +550,11 @@ function SegmentedControl<T extends string>({ value, onChange, options }: {
)
}
/** 后端数据未到时的规则表兜底 (与后端 RULES_META 同步维护) */
const FALLBACK_RULES: Array<{ board: string; st: boolean; thresholds: Record<string, number>; note: string }> = [
{ board: '主板', st: false, thresholds: { '3d': 0.2, '10d': 1.0, '30d': 2.0 }, note: '' },
{ board: '主板', st: true, thresholds: { '3d': 0.15, '10d': 0.5, '30d': 1.0 }, note: '' },
{ board: '创业板/科创板', st: false, thresholds: { '3d': 0.3, '10d': 1.0, '30d': 2.0 }, note: '' },
{ board: '北交所', st: false, thresholds: { '3d': 0.4, '10d': 1.0, '30d': 2.0 }, note: '' },
/** 后端数据未到时的规则表兜底 (与后端 RULES_META 同步维护)
* 阈值为 {正, 负} 双侧: 3日对称, 严重异动 10日+100%/50%、30日+200%/70% (负向更严)
* 2026-07-06 起主板风险警示(ST)股票与普通股票同标准 (原±15%特别规定已废止) */
const FALLBACK_RULES: Array<{ board: string; st: boolean; thresholds: Record<string, { up: number; down: number }>; note: string }> = [
{ board: '板', st: false, thresholds: { '3d': { up: 0.2, down: 0.2 }, '10d': { up: 1.0, down: 0.5 }, '30d': { up: 2.0, down: 0.7 } }, note: '' },
{ board: '创业板/科创板', st: false, thresholds: { '3d': { up: 0.3, down: 0.3 }, '10d': { up: 1.0, down: 0.5 }, '30d': { up: 2.0, down: 0.7 } }, note: '' },
{ board: '北交所', st: false, thresholds: { '3d': { up: 0.4, down: 0.4 }, '10d': { up: 1.0, down: 0.5 }, '30d': { up: 2.0, down: 0.7 } }, note: '' },
]