From 21260f10534a9349a1e867561abc140c7696fa3e Mon Sep 17 00:00:00 2001
From: shy3130 <415333856@qq.com>
Date: Fri, 21 Aug 2026 17:41:35 +0800
Subject: [PATCH] =?UTF-8?q?=E5=BC=82=E5=8A=A8=E7=9B=91=E6=8E=A7=E4=BF=AE?=
=?UTF-8?q?=E5=A4=8D=E4=B8=8E=E8=A7=84=E5=88=99=E5=8F=A3=E5=BE=84=E5=AF=B9?=
=?UTF-8?q?=E9=BD=90=20(=E4=B8=8A=E4=BA=A4=E6=89=80=E4=BA=A4=E6=98=93?=
=?UTF-8?q?=E8=A7=84=E5=88=99=202026=20=E4=BF=AE=E8=AE=A2)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 盘中增量路径补算偏离列: 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%), 跌方向更早触发; 前端规则表/窗口徽标同步双侧显示
---
backend/app/indicators/pipeline.py | 139 ++++++++++++++++++++--
backend/app/services/abnormal_moves.py | 46 ++++----
backend/app/services/quote_service.py | 16 +++
backend/app/tickflow/repository.py | 2 +
backend/tests/test_abnormal_moves.py | 152 ++++++++++++++++++++++++-
frontend/src/lib/api.ts | 5 +-
frontend/src/pages/AbnormalMoves.tsx | 28 +++--
7 files changed, 339 insertions(+), 49 deletions(-)
diff --git a/backend/app/indicators/pipeline.py b/backend/app/indicators/pipeline.py
index f7d9575..3a80d00 100644
--- a/backend/app/indicators/pipeline.py
+++ b/backend/app/indicators/pipeline.py
@@ -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
diff --git a/backend/app/services/abnormal_moves.py b/backend/app/services/abnormal_moves.py
index 68e0cf5..c324570 100644
--- a/backend/app/services/abnormal_moves.py
+++ b/backend/app/services/abnormal_moves.py
@@ -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),
diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py
index 817bddd..9844a5c 100644
--- a/backend/app/services/quote_service.py
+++ b/backend/app/services/quote_service.py
@@ -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)
diff --git a/backend/app/tickflow/repository.py b/backend/app/tickflow/repository.py
index 5e57d0f..c97c4f0 100644
--- a/backend/app/tickflow/repository.py
+++ b/backend/app/tickflow/repository.py
@@ -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"),
diff --git a/backend/tests/test_abnormal_moves.py b/backend/tests/test_abnormal_moves.py
index b6b1d44..521de28 100644
--- a/backend/tests/test_abnormal_moves.py
+++ b/backend/tests/test_abnormal_moves.py
@@ -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
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index cf1a52b..18bf347 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -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