mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
异动监控修复与规则口径对齐 (上交所交易规则 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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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: '' },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user