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 + /** 各窗口双侧阈值 {up: 正向, down: 负向} (小数) */ + thresholds: Record note: string }> counts: { triggered: number; edge: number; watch: number } diff --git a/frontend/src/pages/AbnormalMoves.tsx b/frontend/src/pages/AbnormalMoves.tsx index 6a97e64..384ef28 100644 --- a/frontend/src/pages/AbnormalMoves.tsx +++ b/frontend/src/pages/AbnormalMoves.tsx @@ -194,7 +194,7 @@ export function AbnormalMoves() { 口径说明: 偏离值 = 个股 N 日累计涨跌幅 − 对应指数同期涨跌幅 (沪: 上证A指/上证指数, 深: 深证A指/深证成指, 北: 北证50)。阈值为交易所异常波动披露标准的近似值, 仅供风险提示, 不构成监管认定。每只股票在 3日/10日/30日 三档各算一个接近度 (|偏离值| ÷ 该档阈值, - 阈值随板块与 ST 身份不同), 表格「接近度」列与状态取三档中的最高值, + 阈值随板块不同; 2026-07-06 起主板风险警示股票与普通股票同口径), 表格「接近度」列与状态取三档中的最高值, 来源窗口的偏离值颜色加重显示、其余窗口淡化; ≥100% 已触发、≥70% 边缘、≥50% 观察。 偏离列亦可在自选/选股的「异动」列组中启用, 并可作为监控规则与自定义信号的阈值字段。

@@ -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 (
@@ -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 ( {info ? ( {fmtPct(info.value)} - /{fmtThreshold(info.threshold)} + /{sign}{fmtThreshold(info.threshold)} ) : ( @@ -543,10 +550,11 @@ function SegmentedControl({ value, onChange, options }: { ) } -/** 后端数据未到时的规则表兜底 (与后端 RULES_META 同步维护) */ -const FALLBACK_RULES: Array<{ board: string; st: boolean; thresholds: Record; 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; 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: '' }, ]