mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat(v0.2): 异动监控全链路 (偏离值计算/监控页/系统告警接入) + 发布锁 Windows 存活修复
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
"""异动边缘监控 API — 按交易所异动规则口径统计接近触发的个股。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app.services.abnormal_moves import build_overview
|
||||
|
||||
router = APIRouter(prefix="/api/abnormal", tags=["abnormal"])
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
def abnormal_overview(
|
||||
request: Request,
|
||||
min_closeness: float = Query(0.5, ge=0.0, le=1.0),
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
):
|
||||
"""异动边缘总览: 规则表 + 各窗口实时偏离 + 接近度排序。
|
||||
|
||||
min_closeness: 0.5=观察 / 0.7=边缘 / 1.0=已触发。
|
||||
"""
|
||||
repo = request.app.state.repo
|
||||
quote_service = getattr(request.app.state, "quote_service", None)
|
||||
return build_overview(repo, quote_service, min_closeness=min_closeness, limit=limit)
|
||||
@@ -77,7 +77,7 @@ class RuleModel(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
enabled: bool = True
|
||||
type: str # strategy | signal | price | market | sector
|
||||
type: str # strategy | signal | price | market | sector | abnormal
|
||||
asset_type: str = "stock" # stock | etf (etf: strategy 型走 ETF 历史加载器)
|
||||
scope: str = "symbols" # symbols | all | sector
|
||||
symbols: list[str] = []
|
||||
@@ -88,7 +88,7 @@ class RuleModel(BaseModel):
|
||||
threshold_pct: float = 1.0
|
||||
window_minutes: int = 5
|
||||
strategy_id: str | None = None
|
||||
direction: str = "entry" # entry | exit | both
|
||||
direction: str = "entry" # entry | exit | both | (sector/ladder/abnormal: up|down|both)
|
||||
notify_events: list[str] | None = None
|
||||
score_min: float | None = None
|
||||
score_max: float | None = None
|
||||
@@ -100,6 +100,8 @@ class RuleModel(BaseModel):
|
||||
webhook_enabled: bool = False # 兼容老规则 (已由 webhook_channels 取代, 仅做向后兼容读)
|
||||
webhook_channels: list[str] = [] # 命中时推送的外部渠道 (合法值 'feishu' | 'wecom')
|
||||
message: str = ""
|
||||
# abnormal 专属 (异动边缘监控): any | 3d | 10d | 30d
|
||||
abnormal_window: str = "any"
|
||||
# ladder 专属 (连板梯队封单监控)
|
||||
metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元)
|
||||
threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元)
|
||||
@@ -154,6 +156,7 @@ def get_options(request: Request):
|
||||
{"key": "price", "label": "价格/涨跌"},
|
||||
{"key": "market", "label": "市场异动"},
|
||||
{"key": "strategy", "label": "策略监控"},
|
||||
{"key": "abnormal", "label": "异动监控"},
|
||||
{"key": "sector", "label": "板块监控"},
|
||||
],
|
||||
"scopes": [
|
||||
|
||||
@@ -264,6 +264,7 @@ _WATCHLIST_COLS = [
|
||||
"boll_upper", "boll_lower",
|
||||
"atr_14",
|
||||
"momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d",
|
||||
"deviate_3d", "deviate_10d", "deviate_30d",
|
||||
"consecutive_limit_ups", "consecutive_limit_downs",
|
||||
"signal_limit_up", "signal_limit_down", "signal_volume_surge",
|
||||
"signal_ma_golden_5_20", "signal_macd_golden", "signal_n_day_high",
|
||||
|
||||
@@ -137,7 +137,11 @@ def _process_is_alive(pid: Any) -> bool:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except (OSError, PermissionError):
|
||||
except (OSError, PermissionError) as exc:
|
||||
# Windows 对不存在的 pid 返回 WinError 87 (ERROR_INVALID_PARAMETER),
|
||||
# 不会映射为 ProcessLookupError; 按存活处理会让孤儿发布锁永远无法恢复。
|
||||
if getattr(exc, "winerror", None) == 87:
|
||||
return False
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
@@ -164,6 +164,10 @@ ENRICHED_COLUMNS: dict[str, dict[str, str]] = {
|
||||
"momentum_20d": "20日动量",
|
||||
"momentum_30d": "30日动量",
|
||||
"momentum_60d": "60日动量",
|
||||
# ── 异动偏离 (运行时由 repository 附着, 不落盘) ────────
|
||||
"deviate_3d": "3日涨跌幅偏离值(vs对应指数, 小数)",
|
||||
"deviate_10d": "10日涨跌幅偏离值",
|
||||
"deviate_30d": "30日涨跌幅偏离值",
|
||||
# ── 波动率 ───────────────────────────────────────────
|
||||
"annual_vol_20d": "20日年化波动率",
|
||||
# ── RSI ──────────────────────────────────────────────
|
||||
@@ -210,6 +214,7 @@ ENRICHED_COLUMNS_BY_CATEGORY: dict[str, list[str]] = {
|
||||
"volume": ["vol_ma5", "vol_ma10", "vol_ratio_5d"],
|
||||
"extremes": ["high_60d", "low_60d"],
|
||||
"momentum": ["momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d"],
|
||||
"deviation": ["deviate_3d", "deviate_10d", "deviate_30d"],
|
||||
"volatility": ["annual_vol_20d"],
|
||||
"rsi": ["rsi_6", "rsi_14", "rsi_24"],
|
||||
"signals": [k for k in ENRICHED_COLUMNS if k.startswith("signal_")],
|
||||
@@ -1029,6 +1034,140 @@ def _select_storage_cols(df: pl.DataFrame) -> pl.DataFrame:
|
||||
return df.select(cols)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 异动偏离列 (deviate_3d/10d/30d)
|
||||
#
|
||||
# N 日涨跌幅偏离值 = 个股 N 日累计涨跌幅 - 对应指数同期涨跌幅,
|
||||
# 是交易所「异常波动 / 严重异常波动」规则的量化口径 (如主板 3日±20%,
|
||||
# 10日+100%, 30日+200%)。不属于 compute_indicators 的纯函数范围
|
||||
# (需要指数数据), 因此在 repository 读取路径上附着, 不随 parquet 落盘。
|
||||
# ================================================================
|
||||
|
||||
DEVIATION_WINDOWS: tuple[int, ...] = (3, 10, 30)
|
||||
|
||||
# 各交易所基准指数 (偏离值规则的「对应指数」近似): 优先分类指数, 缺失时回退
|
||||
_BENCHMARK_PREFERENCE: dict[str, list[str]] = {
|
||||
"SH": ["000002.SH", "000001.SH"], # 上证A指 → 上证指数
|
||||
"SZ": ["399107.SZ", "399001.SZ"], # 深证A指 → 深证成指
|
||||
"BJ": ["899050.BJ", "000001.SH"], # 北证50 → 上证指数
|
||||
}
|
||||
|
||||
_benchmark_cache: dict[str, tuple[float, pl.DataFrame | None]] = {}
|
||||
_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。
|
||||
无可用指数数据时返回 None (偏离列置 null, 不阻塞主流程)。
|
||||
进程内按 data_dir 缓存 (TTL 10 分钟)。
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
now = _time.monotonic()
|
||||
key = str(Path(data_dir).resolve())
|
||||
cached = _benchmark_cache.get(key)
|
||||
if cached is not None and now - cached[0] < _BENCHMARK_CACHE_TTL:
|
||||
return cached[1]
|
||||
|
||||
frame: pl.DataFrame | None = None
|
||||
try:
|
||||
index_glob = str(Path(data_dir) / "kline_index_daily" / "**" / "*.parquet")
|
||||
wanted: list[str] = []
|
||||
bench_of: dict[str, str] = {}
|
||||
for exchange, candidates in _BENCHMARK_PREFERENCE.items():
|
||||
for sym in candidates:
|
||||
if sym not in bench_of:
|
||||
wanted.append(sym)
|
||||
bench_of[sym] = exchange
|
||||
lf = scan_daily_parquet(
|
||||
index_glob, cast_options=pl.ScanCastOptions(integer_cast="allow-float")
|
||||
)
|
||||
df_idx = (
|
||||
lf.filter(pl.col("symbol").is_in(wanted))
|
||||
.select(["symbol", "date", "close"])
|
||||
.sort(["symbol", "date"])
|
||||
.collect()
|
||||
)
|
||||
if not df_idx.is_empty():
|
||||
available = set(df_idx["symbol"].to_list())
|
||||
picked = [s for s in wanted if s in available]
|
||||
# 每个交易所取优先级最高的可用基准; 全缺时回退到任一可用基准。
|
||||
# 同一基准可服务多个交易所 (如北证50 缺失时北交所回退上证指数)。
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for exchange, candidates in _BENCHMARK_PREFERENCE.items():
|
||||
hit = next((s for s in candidates if s in available), None)
|
||||
if hit is None and picked:
|
||||
hit = picked[0]
|
||||
if hit is not None:
|
||||
pairs.append((hit, exchange))
|
||||
df_bench = df_idx.filter(pl.col("symbol").is_in([p[0] for p in pairs]))
|
||||
if not df_bench.is_empty():
|
||||
df_bench = df_bench.with_columns(
|
||||
pl.col("close").cast(pl.Float64, strict=False)
|
||||
).with_columns([
|
||||
(pl.col("close") / pl.col("close").shift(n).over("symbol") - 1).alias(f"_bm{n}")
|
||||
for n in DEVIATION_WINDOWS
|
||||
]).rename({f"_bm{n}": f"bench_mom{n}d" for n in DEVIATION_WINDOWS})
|
||||
exchange_map = pl.DataFrame({
|
||||
"symbol": [p[0] for p in pairs],
|
||||
"bench_exchange": [p[1] for p in pairs],
|
||||
})
|
||||
frame = (
|
||||
df_bench.join(exchange_map, on="symbol", how="inner")
|
||||
.select(["date", "bench_exchange", *[f"bench_mom{n}d" for n in DEVIATION_WINDOWS]])
|
||||
.unique(subset=["date", "bench_exchange"])
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("基准指数偏离数据加载失败: %s", exc)
|
||||
frame = None
|
||||
|
||||
_benchmark_cache[key] = (now, frame)
|
||||
return frame
|
||||
|
||||
|
||||
def attach_deviation_columns(df: pl.DataFrame, data_dir: Path) -> pl.DataFrame:
|
||||
"""为已含 momentum_Nd 的 enriched 帧附着 deviate_Nd 偏离列。
|
||||
|
||||
缺失的动量列 (如 momentum_3d 不在指标全集里) 就地按 close 补算,
|
||||
与 compute_indicators 在同一帧上的 shift 语义一致。
|
||||
基准按 symbol 后缀分交易所匹配, join 不上的行 (新上市/基准缺失) 置 null。
|
||||
"""
|
||||
if df.is_empty():
|
||||
return df
|
||||
bench = load_benchmark_momentum(data_dir)
|
||||
dev_cols = [f"deviate_{n}d" for n in DEVIATION_WINDOWS]
|
||||
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 "close" not in df.columns:
|
||||
logger.warning("偏离列附着跳过: 缺少 close 列")
|
||||
return df.with_columns([pl.lit(None, dtype=pl.Float64).alias(c) for c in dev_cols])
|
||||
missing = [n for n in DEVIATION_WINDOWS if f"momentum_{n}d" not in df.columns]
|
||||
if missing:
|
||||
df = df.sort(["symbol", "date"]).with_columns([
|
||||
(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"))
|
||||
.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]])
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def run_pipeline(data_dir: Path | None = None,
|
||||
symbols: list[str] | None = None,
|
||||
new_dates_only: bool = False,
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app import __version__
|
||||
from app.api import (
|
||||
abnormal,
|
||||
alerts,
|
||||
analysis,
|
||||
backtest,
|
||||
@@ -431,6 +432,7 @@ app.include_router(mining.router)
|
||||
app.include_router(intraday.router)
|
||||
app.include_router(indices.router)
|
||||
app.include_router(overview.router)
|
||||
app.include_router(abnormal.router)
|
||||
app.include_router(regime.router)
|
||||
app.include_router(analysis.router)
|
||||
app.include_router(pipeline.router)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""异动边缘统计 — 按交易所异动规则口径实时计算个股接近度。
|
||||
|
||||
规则 (近似口径, 与交易所《交易规则》的异常波动/严重异常波动披露阈值对齐):
|
||||
- 主板: 连续3日收盘价涨跌幅偏离值累计 ±20% (风险警示 ±15%)
|
||||
- 创业板/科创板: 3日 ±30%
|
||||
- 北交所: 3日 ±40%
|
||||
- 严重异常波动: 10日累计偏离 +100% (风险警示 +50%), 30日 +200% (风险警示 +100%)
|
||||
|
||||
偏离值 = 个股 N 日累计涨跌幅 - 对应指数同期涨跌幅 (enriched 运行时列 deviate_Nd)。
|
||||
「接近度」= |实时偏离| / 阈值: ≥1 已触发, ≥0.7 边缘, ≥0.5 观察。
|
||||
盘中实时叠加: 历史偏离 (已完成交易日) + 今日实时涨跌 - 基准指数今日涨跌。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.indicators.pipeline import DEVIATION_WINDOWS
|
||||
|
||||
# ── 规则表 ────────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AbnormalRule:
|
||||
board: str
|
||||
st: bool
|
||||
# 各窗口阈值 (小数): {3: 0.20, 10: 1.00, 30: 2.00}
|
||||
thresholds: dict[int, 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}
|
||||
|
||||
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()},
|
||||
"note": "20%涨跌幅板块, 3日±30%"},
|
||||
{"board": "北交所", "st": False, "thresholds": {f"{k}d": v for k, v in _BSE.items()},
|
||||
"note": "30%涨跌幅板块, 3日±40%"},
|
||||
]
|
||||
|
||||
_BENCH_RT_CANDIDATES = ["000002.SH", "000001.SH", "399107.SZ", "399001.SZ", "899050.BJ"]
|
||||
|
||||
|
||||
def board_of(symbol: str) -> str:
|
||||
"""按代码前缀判定板块。"""
|
||||
code = symbol.split(".")[0]
|
||||
if symbol.endswith(".BJ") or code[:2] in {"43", "83", "87", "92"}:
|
||||
return "北交所"
|
||||
if code.startswith("68"):
|
||||
return "科创板"
|
||||
if code.startswith(("30", "301")):
|
||||
return "创业板"
|
||||
return "主板"
|
||||
|
||||
|
||||
def is_st_name(name: str | None) -> bool:
|
||||
return bool(name) and "ST" in str(name).upper()
|
||||
|
||||
|
||||
def rule_for(symbol: str, name: str | None) -> AbnormalRule:
|
||||
board = board_of(symbol)
|
||||
st = is_st_name(name)
|
||||
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)
|
||||
|
||||
|
||||
# ── 快照计算 ──────────────────────────────────────────────
|
||||
|
||||
_hist_cache_lock = threading.Lock()
|
||||
_hist_cache: dict[str, Any] = {}
|
||||
_HIST_CACHE_TTL = 60.0
|
||||
|
||||
_STATUS_TRIGGERED = "triggered"
|
||||
_STATUS_EDGE = "edge"
|
||||
_STATUS_WATCH = "watch"
|
||||
|
||||
|
||||
def _status_of(closeness: float) -> str:
|
||||
if closeness >= 1.0:
|
||||
return _STATUS_TRIGGERED
|
||||
if closeness >= 0.7:
|
||||
return _STATUS_EDGE
|
||||
return _STATUS_WATCH
|
||||
|
||||
|
||||
def _hist_snapshot(repo: Any) -> dict[str, Any]:
|
||||
"""enriched 最新日的偏离列快照 (60s 进程内缓存)。"""
|
||||
now = time.monotonic()
|
||||
with _hist_cache_lock:
|
||||
cached = _hist_cache.get("data")
|
||||
if cached is not None and now - cached["_ts"] < _HIST_CACHE_TTL:
|
||||
return cached
|
||||
|
||||
df, cache_date = repo.get_enriched_latest()
|
||||
rows: dict[str, dict[str, Any]] = {}
|
||||
if not df.is_empty() and "symbol" in df.columns:
|
||||
cols = ["symbol", *[c for c in ("name", "close", "change_pct",
|
||||
"deviate_3d", "deviate_10d", "deviate_30d") if c in df.columns]]
|
||||
df = df.select(cols)
|
||||
for r in df.iter_rows(named=True):
|
||||
rows[str(r["symbol"])] = {
|
||||
"name": r.get("name"),
|
||||
"close": r.get("close"),
|
||||
"rt_pct": r.get("change_pct"),
|
||||
"deviate_3d": r.get("deviate_3d"),
|
||||
"deviate_10d": r.get("deviate_10d"),
|
||||
"deviate_30d": r.get("deviate_30d"),
|
||||
}
|
||||
payload = {"_ts": now, "rows": rows, "cache_date": cache_date.isoformat() if cache_date else None}
|
||||
with _hist_cache_lock:
|
||||
_hist_cache["data"] = payload
|
||||
return payload
|
||||
|
||||
|
||||
def _bench_rt_pct(quote_service: Any) -> float:
|
||||
"""基准指数今日实时涨跌 (各候选均值, 缺数据时 0)。"""
|
||||
try:
|
||||
df = quote_service.get_index_quotes()
|
||||
except Exception:
|
||||
return 0.0
|
||||
if df is None or df.is_empty():
|
||||
return 0.0
|
||||
df = df.filter(pl.col("symbol").is_in(_BENCH_RT_CANDIDATES))
|
||||
if df.is_empty():
|
||||
return 0.0
|
||||
for col in ("change_pct", "pct", "pct_change"):
|
||||
if col in df.columns:
|
||||
vals = df[col].drop_nulls()
|
||||
if vals.len() > 0:
|
||||
return float(vals.mean())
|
||||
if {"close", "prev_close"} <= set(df.columns):
|
||||
sub = df.select(["close", "prev_close"]).drop_nulls()
|
||||
if sub.height > 0:
|
||||
return float((sub["close"] / sub["prev_close"] - 1).mean())
|
||||
return 0.0
|
||||
|
||||
|
||||
def build_overview(
|
||||
repo: Any,
|
||||
quote_service: Any = None,
|
||||
*,
|
||||
min_closeness: float = 0.5,
|
||||
limit: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
"""返回异动边缘总览: 规则表 + 按接近度排序的个股列表。"""
|
||||
hist = _hist_snapshot(repo)
|
||||
cache_date = hist.get("cache_date")
|
||||
hist_rows: dict[str, dict[str, Any]] = hist["rows"]
|
||||
|
||||
bench_rt = _bench_rt_pct(quote_service) if quote_service is not None else 0.0
|
||||
# enriched 已含今日收盘 (盘后已同步) 时, 今日涨跌已计入历史偏离, 不再叠加
|
||||
includes_today = cache_date is not None and cache_date >= date.today().isoformat()
|
||||
|
||||
out_rows: list[dict[str, Any]] = []
|
||||
for symbol, base in hist_rows.items():
|
||||
rule = rule_for(symbol, base.get("name"))
|
||||
rt_pct = base.get("rt_pct")
|
||||
rt_delta = 0.0 if includes_today else ((rt_pct or 0.0) - bench_rt)
|
||||
|
||||
windows: dict[str, dict[str, Any]] = {}
|
||||
max_closeness = 0.0
|
||||
for n in DEVIATION_WINDOWS:
|
||||
hist_dev = base.get(f"deviate_{n}d")
|
||||
if hist_dev is None:
|
||||
continue
|
||||
live = hist_dev + rt_delta
|
||||
threshold = rule.thresholds[n]
|
||||
closeness = abs(live) / threshold if threshold > 0 else 0.0
|
||||
windows[f"{n}d"] = {
|
||||
"value": round(live, 4),
|
||||
"threshold": threshold,
|
||||
"closeness": round(closeness, 4),
|
||||
}
|
||||
max_closeness = max(max_closeness, closeness)
|
||||
if not windows or max_closeness < min_closeness:
|
||||
continue
|
||||
out_rows.append({
|
||||
"symbol": symbol,
|
||||
"name": base.get("name"),
|
||||
"board": rule.board,
|
||||
"st": rule.st,
|
||||
"close": base.get("close"),
|
||||
"rt_pct": rt_pct,
|
||||
"windows": windows,
|
||||
"max_closeness": round(max_closeness, 4),
|
||||
"status": _status_of(max_closeness),
|
||||
})
|
||||
|
||||
out_rows.sort(key=lambda r: r["max_closeness"], reverse=True)
|
||||
counts = {
|
||||
_STATUS_TRIGGERED: sum(1 for r in out_rows if r["status"] == _STATUS_TRIGGERED),
|
||||
_STATUS_EDGE: sum(1 for r in out_rows if r["status"] == _STATUS_EDGE),
|
||||
_STATUS_WATCH: sum(1 for r in out_rows if r["status"] == _STATUS_WATCH),
|
||||
}
|
||||
return {
|
||||
"asof": time.time(),
|
||||
"cache_date": cache_date,
|
||||
"bench_rt_pct": round(bench_rt, 4),
|
||||
"includes_today": includes_today,
|
||||
"rules": RULES_META,
|
||||
"counts": counts,
|
||||
"rows": out_rows[:limit],
|
||||
}
|
||||
@@ -195,6 +195,9 @@ class QuoteService:
|
||||
self._subscribers: set[QuoteSubscriber] = set()
|
||||
self._strategy_monitor = None # 延迟注入
|
||||
self._app_state = None # 延迟注入 (FastAPI app.state)
|
||||
# 异动边缘规则上次评估时间戳 (秒)。异动快照历史部分有 60s 缓存,
|
||||
# 但每次构建仍有全市场循环, 轮询线程里限频到 30s 一次。
|
||||
self._abnormal_last_eval = 0.0
|
||||
|
||||
# 拉取元信息 (给 SSE / status 用)
|
||||
self._fetch_time: float = 0.0 # perf_counter (用于计算 quote_age_ms)
|
||||
@@ -1123,6 +1126,23 @@ class QuoteService:
|
||||
enriched_today if stock_ready else pl.DataFrame(),
|
||||
self.get_index_quotes(),
|
||||
)
|
||||
# 异动边缘规则轮: 快照 (enriched 偏离列 + 实时叠加) 由
|
||||
# abnormal_moves.build_overview 统一构建, 引擎只做边缘触发判定。
|
||||
# 30s 限频 —— 快照历史部分 60s 缓存, 无需跟行情轮询同频重算。
|
||||
if engine.has_rule_type("abnormal") and self._repo is not None:
|
||||
_now_ts = time.time()
|
||||
if _now_ts - self._abnormal_last_eval >= 30.0:
|
||||
self._abnormal_last_eval = _now_ts
|
||||
try:
|
||||
from app.services import abnormal_moves
|
||||
_overview = abnormal_moves.build_overview(
|
||||
self._repo, self,
|
||||
min_closeness=engine.min_abnormal_closeness(),
|
||||
limit=1000,
|
||||
)
|
||||
rule_events += engine.evaluate_abnormal(_overview.get("rows") or [])
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("异动监控规则评估失败 (不影响其他告警): %s", e)
|
||||
# ETF 规则轮: 股票快照不含 ETF, 用 ETF enriched 快照单独评估。
|
||||
# 独立 try —— ETF 轮任何异常都不得丢弃本轮已算出的股票告警。
|
||||
# refresh=False —— 不在轮询线程上触发 ETF 冷缓存的同步重算 (缓存由 ETF 实时
|
||||
@@ -1182,6 +1202,8 @@ class QuoteService:
|
||||
"sector_source_field", "sector_value", "sector_level",
|
||||
"window_change_pct", "coverage_ratio", "valid_count",
|
||||
"total_count", "up_count", "down_count", "leader",
|
||||
"abnormal_window", "abnormal_value", "abnormal_threshold",
|
||||
"abnormal_closeness",
|
||||
):
|
||||
if key in ev:
|
||||
alert[key] = ev[key]
|
||||
|
||||
@@ -51,6 +51,8 @@ ALLOWED_FIELDS: frozenset[str] = frozenset({
|
||||
"momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d",
|
||||
"annual_vol_20d",
|
||||
"rsi_6", "rsi_14", "rsi_24",
|
||||
# 异动偏离 (交易所异动规则口径, 运行时列)
|
||||
"deviate_3d", "deviate_10d", "deviate_30d",
|
||||
})
|
||||
|
||||
# 运算符 → Polars 表达式构造器(输入 col_expr, value)
|
||||
|
||||
@@ -300,6 +300,9 @@ class MonitorRuleEngine:
|
||||
self._latest_strategy_result_ids: set[str] = set()
|
||||
self._sector_monitor_service = None
|
||||
self._sector_condition_state: dict[tuple[str, str], bool] = {}
|
||||
# abnormal 规则边缘触发状态: (rule_id, symbol) → 上一轮是否已达阈值。
|
||||
# 只在 False → True 跳变时告警 (首轮观测不触发, 防止新建规则瞬间刷屏)。
|
||||
self._abnormal_condition_state: dict[tuple[str, str], bool] = {}
|
||||
|
||||
def set_strategy_engine(self, engine) -> None:
|
||||
"""注入 StrategyEngine, type=strategy 规则据此跑选股。"""
|
||||
@@ -371,6 +374,7 @@ class MonitorRuleEngine:
|
||||
rule.get("direction"),
|
||||
rule.get("threshold_pct"),
|
||||
rule.get("window_minutes"),
|
||||
rule.get("abnormal_window"),
|
||||
)
|
||||
|
||||
def set_rules(self, rules: list[dict]) -> None:
|
||||
@@ -413,6 +417,11 @@ class MonitorRuleEngine:
|
||||
for key, value in list(self._sector_condition_state.items())
|
||||
if key[0] in active_ids
|
||||
}
|
||||
self._abnormal_condition_state = {
|
||||
key: value
|
||||
for key, value in list(self._abnormal_condition_state.items())
|
||||
if key[0] in active_ids
|
||||
}
|
||||
logger.info("MonitorRuleEngine: 装载 %d 条规则", len(self._rules))
|
||||
|
||||
def add_rule(self, rule: dict) -> None:
|
||||
@@ -607,7 +616,7 @@ class MonitorRuleEngine:
|
||||
for rule_id, rule in list(self._rules.items()):
|
||||
if rule.get("asset_type", "stock") != asset_type:
|
||||
continue
|
||||
if rule.get("type") == "sector":
|
||||
if rule.get("type") in ("sector", "abnormal"):
|
||||
continue
|
||||
try:
|
||||
events.extend(self._evaluate_rule(df, rule, now))
|
||||
@@ -771,6 +780,133 @@ class MonitorRuleEngine:
|
||||
)
|
||||
return "|".join(parts)
|
||||
|
||||
def min_abnormal_closeness(self) -> float:
|
||||
"""启用的 abnormal 规则中最小的接近度阈值 (小数)。
|
||||
|
||||
供调用方 (quote_service) 构建异动快照时预过滤, 不必按最高阈值拉全量。
|
||||
"""
|
||||
thresholds = [
|
||||
float(r.get("threshold_pct", 70)) / 100
|
||||
for r in list(self._rules.values())
|
||||
if r.get("enabled", True) and r.get("type") == "abnormal"
|
||||
]
|
||||
return min(thresholds) if thresholds else 1.0
|
||||
|
||||
def evaluate_abnormal(self, rows: list[dict], *, now: float | None = None) -> list[dict]:
|
||||
"""按异动边缘快照评估 type=abnormal 规则。
|
||||
|
||||
rows 为 abnormal_moves.build_overview 的 rows (调用方已按
|
||||
min_abnormal_closeness 预过滤)。rows 为空也照常评估 —— 用于把
|
||||
已消失标的的边缘状态清理回 False。
|
||||
"""
|
||||
rules = [
|
||||
rule for rule in list(self._rules.values())
|
||||
if rule.get("enabled", True) and rule.get("type") == "abnormal"
|
||||
]
|
||||
if not rules:
|
||||
return []
|
||||
timestamp = time.time() if now is None else now
|
||||
events: list[dict] = []
|
||||
for rule in rules:
|
||||
try:
|
||||
events.extend(self._evaluate_abnormal_rule(rule, rows, timestamp))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("异动规则评估失败 %s: %s", rule.get("id"), exc)
|
||||
return events
|
||||
|
||||
def _evaluate_abnormal_rule(self, rule: dict, rows: list[dict], now: float) -> list[dict]:
|
||||
events: list[dict] = []
|
||||
threshold = float(rule.get("threshold_pct", 70)) / 100
|
||||
if not 0 < threshold <= 1.5:
|
||||
threshold = 0.7
|
||||
direction = rule.get("direction", "both")
|
||||
window_filter = str(rule.get("abnormal_window", "any"))
|
||||
scope_symbols = (
|
||||
{str(s) for s in rule.get("symbols", []) if s}
|
||||
if rule.get("scope") == "symbols" else None
|
||||
)
|
||||
|
||||
seen: set[str] = set()
|
||||
for row in rows:
|
||||
symbol = str(row.get("symbol") or "")
|
||||
if not symbol or (scope_symbols is not None and symbol not in scope_symbols):
|
||||
continue
|
||||
seen.add(symbol)
|
||||
# 方向/窗口过滤后取接近度最高的窗口作为代表
|
||||
best: tuple[str, float, float, float] | None = None # (窗口, 接近度, 偏离值, 阈值)
|
||||
for key, win in (row.get("windows") or {}).items():
|
||||
if window_filter != "any" and key != window_filter:
|
||||
continue
|
||||
value = win.get("value")
|
||||
if value is None:
|
||||
continue
|
||||
if direction == "up" and value <= 0:
|
||||
continue
|
||||
if direction == "down" and value >= 0:
|
||||
continue
|
||||
closeness = float(win.get("closeness") or 0)
|
||||
if best is None or closeness > best[1]:
|
||||
best = (key, closeness, float(value), float(win.get("threshold") or 0))
|
||||
condition = best is not None and best[1] >= threshold
|
||||
state_key = (rule["id"], symbol)
|
||||
previous = self._abnormal_condition_state.get(state_key)
|
||||
self._abnormal_condition_state[state_key] = condition
|
||||
if previous is None or previous or not condition:
|
||||
continue
|
||||
|
||||
event_type = f"abnormal_{'up' if best[2] > 0 else 'down'}"
|
||||
cooldown_key = (rule["id"], symbol, event_type)
|
||||
last = self._last_fire.get(cooldown_key)
|
||||
cooldown = int(rule.get("cooldown_seconds", 3600))
|
||||
if last is not None and now - last < cooldown:
|
||||
continue
|
||||
self._last_fire[cooldown_key] = now
|
||||
event = {
|
||||
"ts": int(now * 1000),
|
||||
"rule_id": rule["id"],
|
||||
"rule_name": rule.get("name", ""),
|
||||
"strategy_id": None,
|
||||
"source": "abnormal",
|
||||
"type": event_type,
|
||||
"symbol": symbol,
|
||||
"name": row.get("name"),
|
||||
"message": rule.get("message", "") or self._abnormal_message(row, best),
|
||||
"price": row.get("close"),
|
||||
"change_pct": row.get("rt_pct"),
|
||||
"signals": [],
|
||||
"severity": rule.get("severity", "info"),
|
||||
"conditions": [],
|
||||
"logic": "and",
|
||||
"abnormal_window": best[0],
|
||||
"abnormal_value": round(best[2], 4),
|
||||
"abnormal_threshold": best[3],
|
||||
"abnormal_closeness": round(best[1], 4),
|
||||
}
|
||||
events.append(event)
|
||||
if self._alert_handler:
|
||||
try:
|
||||
self._alert_handler(event)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("alert handler failed: %s", exc)
|
||||
# 本轮未出现的标的 (跌出预过滤区间) 状态置 False 而非删除:
|
||||
# 删除会被当成「首轮观测」而不触发, 置 False 才能在回升穿过阈值时再次告警。
|
||||
for key, value in list(self._abnormal_condition_state.items()):
|
||||
if key[0] == rule["id"] and key[1] not in seen and value:
|
||||
self._abnormal_condition_state[key] = False
|
||||
return events
|
||||
|
||||
@staticmethod
|
||||
def _abnormal_message(row: dict, best: tuple[str, float, float, float]) -> str:
|
||||
window, closeness, value, threshold = best
|
||||
board = row.get("board") or ""
|
||||
tag = f"{board}{'·ST' if row.get('st') else ''}"
|
||||
state = "已达异常波动阈值" if closeness >= 1 else "接近异常波动阈值"
|
||||
return (
|
||||
f"{row.get('name') or row.get('symbol')} {window}偏离值 "
|
||||
f"{value * 100:+.2f}%/阈值{threshold * 100:.0f}% ({tag}) "
|
||||
f"接近度{closeness * 100:.0f}%, {state}"
|
||||
)
|
||||
|
||||
def _evaluate_rule(self, df: pl.DataFrame, rule: dict, now: float) -> list[dict]:
|
||||
"""评估单条规则,返回触发的 events。"""
|
||||
# 1. 按 scope 过滤作用域
|
||||
|
||||
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 常量 ────────────────────────────────────────────────
|
||||
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||
RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector"}
|
||||
RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector", "abnormal"}
|
||||
SCOPES = {"symbols", "all", "sector"}
|
||||
LOGICS = {"and", "or"}
|
||||
DIRECTIONS = {"entry", "exit", "both"}
|
||||
@@ -42,6 +42,9 @@ LADDER_DIRECTIONS = {"up", "down"}
|
||||
SECTOR_KINDS = {"index", "concept", "industry"}
|
||||
SECTOR_TRIGGERS = {"change_pct", "momentum"}
|
||||
SECTOR_WINDOWS = {1, 3, 5, 10, 15}
|
||||
# abnormal 规则 (异动边缘): 接近度方向 / 关注窗口
|
||||
ABNORMAL_DIRECTIONS = {"up", "down", "both"}
|
||||
ABNORMAL_WINDOWS = {"any", "3d", "10d", "30d"}
|
||||
|
||||
# 布尔信号列前缀 (op=truth 时 field 取这些)
|
||||
_SIGNAL_PREFIXES = ("signal_", "csg_")
|
||||
@@ -176,6 +179,17 @@ def validate(rule: dict) -> None:
|
||||
raise ValueError("板块监控阈值必须大于 0 且不超过 20%")
|
||||
if rule.get("sector_trigger") == "momentum" and rule.get("window_minutes") not in SECTOR_WINDOWS:
|
||||
raise ValueError(f"板块异动窗口必须是 {sorted(SECTOR_WINDOWS)} 分钟之一")
|
||||
elif rule.get("type") == "abnormal":
|
||||
# 异动边缘监控: threshold_pct = 接近度阈值% (|偏离值|/规则阈值), 不用 conditions
|
||||
if rule.get("asset_type", "stock") != "stock":
|
||||
raise ValueError("异动监控仅支持个股 (偏离值仅对个股计算)")
|
||||
if rule.get("direction", "both") not in ABNORMAL_DIRECTIONS:
|
||||
raise ValueError(f"异动监控 direction 必须是 {ABNORMAL_DIRECTIONS} 之一")
|
||||
if rule.get("abnormal_window", "any") not in ABNORMAL_WINDOWS:
|
||||
raise ValueError(f"异动监控窗口必须是 {sorted(ABNORMAL_WINDOWS)} 之一")
|
||||
threshold_pct = rule.get("threshold_pct")
|
||||
if not isinstance(threshold_pct, (int, float)) or not 1 <= threshold_pct <= 150:
|
||||
raise ValueError("异动接近度阈值必须是 1 到 150 之间的百分比数字")
|
||||
else:
|
||||
# 信号/价格/市场类型: 需要 conditions
|
||||
conds = rule.get("conditions")
|
||||
@@ -231,17 +245,21 @@ def normalize(rule: dict) -> dict:
|
||||
r = dict(rule)
|
||||
r.setdefault("enabled", True)
|
||||
r.setdefault("asset_type", "stock")
|
||||
r.setdefault("scope", "symbols")
|
||||
# sector/abnormal 默认全市场 (sector 随后强制 all; abnormal 支持指定标的)
|
||||
r.setdefault("scope", "all" if r.get("type") in {"sector", "abnormal"} else "symbols")
|
||||
r.setdefault("symbols", [])
|
||||
r.setdefault("sector", None)
|
||||
r.setdefault("sector_kind", None)
|
||||
r.setdefault("sector_targets", [])
|
||||
r.setdefault("sector_trigger", "change_pct")
|
||||
r.setdefault("threshold_pct", 1.0)
|
||||
r.setdefault("threshold_pct", 70.0 if r.get("type") == "abnormal" else 1.0)
|
||||
r.setdefault("window_minutes", 5)
|
||||
r.setdefault("strategy_id", None)
|
||||
# direction 默认值: ladder/sector 用 "up", 其余用 "entry"
|
||||
r.setdefault("direction", "up" if r.get("type") in {"ladder", "sector"} else "entry")
|
||||
# direction 默认值: ladder/sector 用 "up", abnormal 用 "both", 其余用 "entry"
|
||||
r.setdefault(
|
||||
"direction",
|
||||
"up" if r.get("type") in {"ladder", "sector"} else "both" if r.get("type") == "abnormal" else "entry",
|
||||
)
|
||||
if r.get("type") == "strategy":
|
||||
r.setdefault("score_min", None)
|
||||
r.setdefault("score_max", None)
|
||||
@@ -261,6 +279,8 @@ def normalize(rule: dict) -> dict:
|
||||
if r.get("type") == "sector":
|
||||
r["scope"] = "all"
|
||||
r["symbols"] = []
|
||||
# abnormal 专属默认字段 (异动边缘监控)
|
||||
r.setdefault("abnormal_window", "any")
|
||||
r.setdefault("logic", "and")
|
||||
r.setdefault("cooldown_seconds", 3600)
|
||||
r.setdefault("severity", "info")
|
||||
|
||||
@@ -578,6 +578,10 @@ class KlineRepository:
|
||||
df_full = compute_indicators(df_hist)
|
||||
logger.info("enriched refresh step done: compute indicators rows=%d (%.2fs)", len(df_full), time.perf_counter() - step)
|
||||
|
||||
# 异动偏离列 (deviate_Nd = 个股动量 - 基准指数动量), 运行时附着
|
||||
from app.indicators.pipeline import attach_deviation_columns
|
||||
df_full = attach_deviation_columns(df_full, self.store.data_dir)
|
||||
|
||||
step = time.perf_counter()
|
||||
logger.info("enriched refresh step start: compute signals")
|
||||
df_full = compute_signals(df_full)
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""异动边缘统计测试 — 偏离列附着 + 规则口径 + 快照接近度。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.indicators.pipeline import attach_deviation_columns, load_benchmark_momentum
|
||||
from app.services.abnormal_moves import (
|
||||
_hist_cache,
|
||||
_hist_cache_lock,
|
||||
board_of,
|
||||
build_overview,
|
||||
is_st_name,
|
||||
rule_for,
|
||||
)
|
||||
|
||||
|
||||
def _write_index_daily(tmp_path, rows: list[tuple[str, date, float]]) -> None:
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"symbol": [r[0] for r in rows],
|
||||
"date": [r[1] for r in rows],
|
||||
"close": [r[2] for r in rows],
|
||||
}
|
||||
)
|
||||
for dt in sorted({r[1] for r in rows}):
|
||||
target = tmp_path / "kline_index_daily" / f"date={dt.isoformat()}"
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
df.filter(pl.col("date") == dt).write_parquet(target / "part.parquet")
|
||||
|
||||
|
||||
def test_attach_deviation_columns_math(tmp_path) -> None:
|
||||
# 上证指数 4 天等差 +1: 3日动量 = 13/10-1 = 0.30
|
||||
# 个股 close 与指数同序列 → momentum_3d 缺失时按 close 就地补算, 偏离 = 0
|
||||
days = [date(2026, 8, 13), date(2026, 8, 14), date(2026, 8, 15), date(2026, 8, 18)]
|
||||
index_rows = [("000001.SH", d, 10.0 + i) for i, d in enumerate(days)]
|
||||
_write_index_daily(tmp_path, index_rows)
|
||||
|
||||
stock = pl.DataFrame(
|
||||
{
|
||||
"symbol": ["600000.SH"] * len(days),
|
||||
"date": days,
|
||||
"close": [10.0 + i for i in range(len(days))],
|
||||
# 10/30 日窗口已有动量列 → 直接使用
|
||||
"momentum_10d": [None] * 4,
|
||||
"momentum_30d": [None] * 4,
|
||||
}
|
||||
)
|
||||
out = attach_deviation_columns(stock, tmp_path)
|
||||
assert "deviate_3d" in out.columns
|
||||
assert "momentum_3d" in out.columns # 就地补算
|
||||
last = out.sort("date").row(-1, named=True)
|
||||
assert abs(last["deviate_3d"] - 0.0) < 1e-9
|
||||
|
||||
|
||||
def test_attach_deviation_columns_missing_benchmark(tmp_path) -> None:
|
||||
# 无指数数据: 偏离列为 null, 不抛异常
|
||||
stock = pl.DataFrame(
|
||||
{
|
||||
"symbol": ["600000.SH"],
|
||||
"date": [date(2026, 8, 18)],
|
||||
"momentum_3d": [0.2],
|
||||
"momentum_10d": [0.5],
|
||||
"momentum_30d": [1.0],
|
||||
}
|
||||
)
|
||||
out = attach_deviation_columns(stock, tmp_path)
|
||||
assert out["deviate_3d"][0] is None
|
||||
|
||||
|
||||
def test_board_and_st_rules() -> None:
|
||||
assert board_of("600000.SH") == "主板"
|
||||
assert board_of("000001.SZ") == "主板"
|
||||
assert board_of("301123.SZ") == "创业板"
|
||||
assert board_of("688123.SH") == "科创板"
|
||||
assert board_of("920001.BJ") == "北交所"
|
||||
assert is_st_name("*ST 某某") is True
|
||||
assert is_st_name("正常股") is False
|
||||
|
||||
main = rule_for("600000.SH", "正常股")
|
||||
assert main.thresholds == {3: 0.20, 10: 1.00, 30: 2.00}
|
||||
st = rule_for("600000.SH", "ST 某某")
|
||||
assert st.thresholds == {3: 0.15, 10: 0.50, 30: 1.00}
|
||||
gem = rule_for("301123.SZ", "正常股")
|
||||
assert gem.thresholds[3] == 0.30
|
||||
bse = rule_for("920001.BJ", "正常股")
|
||||
assert bse.thresholds[3] == 0.40
|
||||
|
||||
|
||||
class _FakeRepo:
|
||||
"""最小 repo: get_enriched_latest 返回构造帧。"""
|
||||
|
||||
def __init__(self, df: pl.DataFrame) -> None:
|
||||
self._df = df
|
||||
|
||||
def get_enriched_latest(self):
|
||||
return self._df, date(2026, 8, 19)
|
||||
|
||||
|
||||
class _FakeQuotes:
|
||||
def get_index_quotes(self):
|
||||
return pl.DataFrame(
|
||||
{"symbol": ["000001.SH"], "close": [3300.0], "prev_close": [3270.0]}
|
||||
)
|
||||
|
||||
|
||||
def test_build_overview_closeness_and_status() -> None:
|
||||
with _hist_cache_lock:
|
||||
_hist_cache.clear()
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"symbol": ["600000.SH", "300001.SZ", "000002.SZ"],
|
||||
"name": ["股A", "股B", "股C"],
|
||||
"close": [10.0, 20.0, 30.0],
|
||||
"change_pct": [0.05, 0.02, 0.01],
|
||||
"deviate_3d": [0.19, 0.35, 0.05],
|
||||
"deviate_10d": [0.99, 0.40, 0.20],
|
||||
"deviate_30d": [1.95, 2.10, 0.60],
|
||||
}
|
||||
)
|
||||
result = build_overview(_FakeRepo(df), _FakeQuotes(), min_closeness=0.5, limit=10)
|
||||
|
||||
by_symbol = {r["symbol"]: r for r in result["rows"]}
|
||||
# 主板: 3d阈值0.2 → 0.19/0.2=0.95 边缘; 指数实时 +30/3270≈0.00917 叠加后略增
|
||||
a = by_symbol["600000.SH"]
|
||||
assert a["status"] in ("edge", "triggered")
|
||||
# 创业板: 30日 2.10/2.00 ≥ 1 → triggered
|
||||
b = by_symbol["300001.SZ"]
|
||||
assert b["status"] == "triggered"
|
||||
# 000002: 3d 0.05/0.2=0.25, 10d 0.2/1=0.2, 30d 0.6/2=0.3 → 全部 < 0.5 被过滤
|
||||
assert "000002.SZ" not in by_symbol
|
||||
# 排序按接近度降序
|
||||
closeness = [r["max_closeness"] for r in result["rows"]]
|
||||
assert closeness == sorted(closeness, reverse=True)
|
||||
assert result["counts"]["triggered"] >= 1
|
||||
|
||||
|
||||
def test_build_overview_cache_date_today_no_double_count() -> None:
|
||||
"""cache_date >= 今天时不再叠加实时涨跌 (避免重复计入)。"""
|
||||
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"],
|
||||
"name": ["股A"],
|
||||
"close": [10.0],
|
||||
"change_pct": [0.05],
|
||||
"deviate_3d": [0.19],
|
||||
"deviate_10d": [None],
|
||||
"deviate_30d": [None],
|
||||
}
|
||||
)
|
||||
result = build_overview(_TodayRepo(df), _FakeQuotes(), min_closeness=0.5)
|
||||
row = result["rows"][0]
|
||||
assert abs(row["windows"]["3d"]["value"] - 0.19) < 1e-9
|
||||
|
||||
|
||||
# ── 监控规则接入 (type=abnormal) ────────────────────────
|
||||
|
||||
import pytest
|
||||
|
||||
from app.strategy import monitor_rules
|
||||
from app.strategy.monitor import MonitorRuleEngine
|
||||
|
||||
|
||||
def _ab_rule(**overrides) -> dict:
|
||||
rule = {
|
||||
"id": "r_ab",
|
||||
"name": "异动边缘",
|
||||
"type": "abnormal",
|
||||
"scope": "all",
|
||||
"symbols": [],
|
||||
"threshold_pct": 70,
|
||||
"direction": "both",
|
||||
"abnormal_window": "any",
|
||||
"cooldown_seconds": 0,
|
||||
"severity": "warn",
|
||||
}
|
||||
rule.update(overrides)
|
||||
return rule
|
||||
|
||||
|
||||
def _row(symbol: str, *wins: tuple[str, float], name: str = "股A",
|
||||
board: str = "主板", rt_pct: float = 0.05) -> dict:
|
||||
# wins: (窗口, 偏离值) — 阈值按交易所口径: 主板 3d=0.2, 10d=1.0, 30d=2.0
|
||||
thresholds = {"3d": 0.2, "10d": 1.0, "30d": 2.0}
|
||||
windows = {
|
||||
key: {"value": value, "threshold": thresholds[key],
|
||||
"closeness": round(abs(value) / thresholds[key], 4)}
|
||||
for key, value in wins
|
||||
}
|
||||
return {"symbol": symbol, "name": name, "board": board, "st": False,
|
||||
"close": 10.0, "rt_pct": rt_pct, "windows": windows}
|
||||
|
||||
|
||||
def test_abnormal_rule_validation_and_defaults() -> None:
|
||||
rule = monitor_rules.normalize({"id": "r1", "name": "n", "type": "abnormal"})
|
||||
assert rule["direction"] == "both"
|
||||
assert rule["threshold_pct"] == 70.0
|
||||
assert rule["abnormal_window"] == "any"
|
||||
monitor_rules.validate(rule)
|
||||
|
||||
monitor_rules.validate(_ab_rule(threshold_pct=100, direction="up", abnormal_window="3d"))
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
monitor_rules.validate(_ab_rule(abnormal_window="5d"))
|
||||
with pytest.raises(ValueError):
|
||||
monitor_rules.validate(_ab_rule(threshold_pct=0.5))
|
||||
with pytest.raises(ValueError):
|
||||
monitor_rules.validate(_ab_rule(asset_type="etf"))
|
||||
with pytest.raises(ValueError):
|
||||
monitor_rules.validate(_ab_rule(direction="entry"))
|
||||
|
||||
|
||||
def test_engine_abnormal_edge_trigger_and_cooldown() -> None:
|
||||
engine = MonitorRuleEngine()
|
||||
engine.set_rules([_ab_rule()])
|
||||
assert engine.min_abnormal_closeness() == pytest.approx(0.7)
|
||||
|
||||
# 首轮观测不触发 (防新建规则刷屏); 0.10/0.2 = 50% 接近度, 低于阈值
|
||||
assert engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.10))], now=1000.0) == []
|
||||
# 上穿 70% → 触发 (0.16/0.2 = 80%)
|
||||
events = engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.16))], now=1006.0)
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert ev["source"] == "abnormal"
|
||||
assert ev["type"] == "abnormal_up"
|
||||
assert ev["symbol"] == "600000.SH"
|
||||
assert ev["abnormal_window"] == "3d"
|
||||
assert ev["abnormal_closeness"] == pytest.approx(0.8)
|
||||
assert "接近" in ev["message"] or "已达" in ev["message"]
|
||||
# 持续高于阈值: 不重复触发 (边缘语义)
|
||||
assert engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.18))], now=1012.0) == []
|
||||
# 回落再上穿: cooldown=0 时再次触发
|
||||
engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.10))], now=1018.0)
|
||||
assert len(engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.17))], now=1024.0)) == 1
|
||||
|
||||
# cooldown 内的上穿被抑制
|
||||
engine_cd = MonitorRuleEngine()
|
||||
engine_cd.set_rules([_ab_rule(cooldown_seconds=3600)])
|
||||
engine_cd.evaluate_abnormal([_row("600000.SH", ("3d", 0.10))], now=1000.0)
|
||||
engine_cd.evaluate_abnormal([_row("600000.SH", ("3d", 0.16))], now=1006.0)
|
||||
engine_cd.evaluate_abnormal([_row("600000.SH", ("3d", 0.10))], now=1012.0)
|
||||
assert engine_cd.evaluate_abnormal([_row("600000.SH", ("3d", 0.16))], now=1018.0) == []
|
||||
|
||||
|
||||
def test_engine_abnormal_stale_symbol_state_cleared() -> None:
|
||||
"""标的跌出快照后状态应清回 False, 回升穿过阈值时可再次触发。"""
|
||||
engine = MonitorRuleEngine()
|
||||
engine.set_rules([_ab_rule()])
|
||||
engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.10))], now=1000.0) # 首轮 False
|
||||
assert len(engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.18))], now=1006.0)) == 1
|
||||
# 跌出预过滤区间 (快照中消失)
|
||||
engine.evaluate_abnormal([], now=1012.0)
|
||||
# 重新出现且超阈值 → 重新触发
|
||||
assert len(engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.18))], now=1018.0)) == 1
|
||||
|
||||
|
||||
def test_engine_abnormal_direction_window_scope_filters() -> None:
|
||||
# 方向: 只报上涨偏离
|
||||
engine = MonitorRuleEngine()
|
||||
engine.set_rules([_ab_rule(direction="up")])
|
||||
engine.evaluate_abnormal([_row("600000.SH", ("3d", -0.16))], now=1000.0)
|
||||
assert engine.evaluate_abnormal([_row("600000.SH", ("3d", -0.19))], now=1006.0) == []
|
||||
|
||||
# 窗口: 只看 3d (10d/30d 的偏离不参与)
|
||||
engine = MonitorRuleEngine()
|
||||
engine.set_rules([_ab_rule(abnormal_window="3d")])
|
||||
engine.evaluate_abnormal([_row("600000.SH", ("10d", 0.98))], now=1000.0)
|
||||
assert engine.evaluate_abnormal([_row("600000.SH", ("10d", 0.99))], now=1006.0) == []
|
||||
|
||||
# 作用域: 只监控指定标的
|
||||
engine = MonitorRuleEngine()
|
||||
engine.set_rules([_ab_rule(scope="symbols", symbols=["600000.SH"])])
|
||||
engine.evaluate_abnormal(
|
||||
[_row("600000.SH", ("3d", 0.10)), _row("000001.SZ", ("3d", 0.10))], now=1000.0,
|
||||
)
|
||||
events = engine.evaluate_abnormal(
|
||||
[_row("600000.SH", ("3d", 0.16)), _row("000001.SZ", ("3d", 0.19))], now=1006.0,
|
||||
)
|
||||
assert [ev["symbol"] for ev in events] == ["600000.SH"]
|
||||
|
||||
|
||||
def test_engine_abnormal_down_direction_event_type() -> None:
|
||||
engine = MonitorRuleEngine()
|
||||
engine.set_rules([_ab_rule(direction="down")])
|
||||
engine.evaluate_abnormal([_row("600000.SH", ("3d", -0.10))], now=1000.0)
|
||||
events = engine.evaluate_abnormal([_row("600000.SH", ("3d", -0.16))], now=1006.0)
|
||||
assert len(events) == 1
|
||||
assert events[0]["type"] == "abnormal_down"
|
||||
@@ -86,6 +86,41 @@ def test_recovery_replaces_stale_publication_but_not_active_owner(tmp_path) -> N
|
||||
assert pl.read_parquet(out)["close"].item() == pytest.approx(12.0)
|
||||
|
||||
|
||||
def test_recovery_takes_over_when_owner_pid_is_dead_on_windows(tmp_path, monkeypatch) -> None:
|
||||
# 跨进程孤儿锁: 属主进程已死, 但 Windows 的 os.kill(pid, 0) 对不存在的 pid
|
||||
# 抛 WinError 87 (ERROR_INVALID_PARAMETER) 而非 ProcessLookupError,
|
||||
# 存活探测若把它当"存活", recover 将永远报 another publication is active。
|
||||
stale = {
|
||||
"state": "publishing",
|
||||
"generation": "stale-generation",
|
||||
"publication_id": "stale-publication",
|
||||
"owner_pid": 12345,
|
||||
"updated_at_ns": 0,
|
||||
}
|
||||
(tmp_path / ".matrix_generation_stock.json").write_text(
|
||||
json.dumps(stale), encoding="utf-8"
|
||||
)
|
||||
|
||||
def probe(_pid: int, _sig: int) -> None:
|
||||
error = OSError()
|
||||
error.winerror = 87
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr("app.enriched_generation.os.kill", probe)
|
||||
|
||||
out = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
|
||||
recovered = EnrichedPublication(tmp_path, recover=True)
|
||||
recovered.write_parquet(_frame(10.0), out)
|
||||
recovered.commit()
|
||||
|
||||
marker = json.loads(
|
||||
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert marker["state"] == "ready"
|
||||
assert get_enriched_generation(tmp_path, "stock") == marker["generation"]
|
||||
assert pl.read_parquet(out)["close"].item() == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_panel_cache_generation_change_forces_recompute() -> None:
|
||||
cache = PanelCache()
|
||||
calls: list[int] = []
|
||||
|
||||
@@ -89,6 +89,7 @@ const SOURCE_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
price: { label: '价格', cls: 'bg-emerald-400/15 text-emerald-400' },
|
||||
market: { label: '异动', cls: 'bg-purple-500/15 text-purple-400' },
|
||||
sector: { label: '板块', cls: 'bg-cyan-500/15 text-cyan-700 dark:text-cyan-300' },
|
||||
abnormal: { label: '异动边缘', cls: 'bg-orange-500/15 text-orange-500 dark:text-orange-400' },
|
||||
pool_entry: { label: '进入', cls: 'bg-danger/15 text-danger' },
|
||||
pool_exit: { label: '移出', cls: 'bg-bear/15 text-bear' },
|
||||
buy_signal: { label: '买入', cls: 'bg-danger/15 text-danger' },
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { tierRank } from '@/lib/capability-labels'
|
||||
import {
|
||||
Siren,
|
||||
Star,
|
||||
ScanSearch,
|
||||
History,
|
||||
@@ -90,6 +91,7 @@ const nav = [
|
||||
{ to: '/financials', label: '财务分析', icon: FileText },
|
||||
{ to: '/monitor', label: '监控中心', icon: RadioTower },
|
||||
{ to: '/regime', label: '市场环境', icon: Gauge },
|
||||
{ to: '/abnormal', label: '异动监控', icon: Siren },
|
||||
{ to: '/review', label: '复盘', icon: BookOpenCheck },
|
||||
{ to: '/indices', label: '指数', icon: BarChart3 },
|
||||
{ to: '/data', label: '数据', icon: Database },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Activity, Building2, ChartNoAxesCombined, Check, ChevronDown, ChevronUp, Eraser, Layers3, ListPlus, Plus, RadioTower, Save, Search, Tags, TrendingUp, Waypoints, X } from 'lucide-react'
|
||||
import { Activity, Building2, ChartNoAxesCombined, Check, ChevronDown, ChevronUp, Eraser, Layers3, ListPlus, Plus, RadioTower, Save, Search, Siren, Tags, TrendingUp, Waypoints, X } from 'lucide-react'
|
||||
import { api, genRuleId, type MonitorRule, type MonitorCondition, type SectorKind, type SectorMonitorTarget, type StrategyNotifyEvent } from '@/lib/api'
|
||||
import { DEFAULT_STRATEGY_NOTIFY_EVENTS, LEGACY_STRATEGY_NOTIFY_EVENTS, STRATEGY_NOTIFY_EVENT_OPTIONS } from '@/lib/strategyMonitorEvents'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
@@ -23,7 +23,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const TYPE_DEFAULT_NAME: Record<string, string> = {
|
||||
signal: '信号监控', price: '价格监控', market: '市场异动监控', strategy: '策略监控', sector: '板块监控',
|
||||
signal: '信号监控', price: '价格监控', market: '市场异动监控', strategy: '策略监控', sector: '板块监控', abnormal: '异动监控',
|
||||
}
|
||||
|
||||
const TYPE_ICONS = {
|
||||
@@ -32,6 +32,7 @@ const TYPE_ICONS = {
|
||||
market: RadioTower,
|
||||
strategy: Waypoints,
|
||||
sector: Layers3,
|
||||
abnormal: Siren,
|
||||
}
|
||||
|
||||
const SECTOR_KIND_OPTIONS: Array<{ key: SectorKind; label: string; icon: typeof ChartNoAxesCombined }> = [
|
||||
@@ -61,6 +62,7 @@ const emptyRule = (preset?: Partial<MonitorRule>): MonitorRule => ({
|
||||
sector_trigger: 'change_pct',
|
||||
threshold_pct: 1,
|
||||
window_minutes: 5,
|
||||
abnormal_window: 'any',
|
||||
strategy_id: null,
|
||||
score_min: null,
|
||||
score_max: null,
|
||||
@@ -157,6 +159,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
const base = TYPE_DEFAULT_NAME[d.type] ?? '监控规则'
|
||||
d.name = d.type === 'sector' && d.sector_targets?.length
|
||||
? `${base} · ${d.sector_targets[0].name}${d.sector_targets.length > 1 ? ` 等${d.sector_targets.length}个` : ''}`
|
||||
: d.type === 'abnormal'
|
||||
? `${base} · 接近度≥${d.threshold_pct ?? 70}%${d.abnormal_window && d.abnormal_window !== 'any' ? ` (${d.abnormal_window.toUpperCase()})` : ''}`
|
||||
: d.scope === 'symbols' && d.symbols.length > 0
|
||||
? `${base} · ${d.symbols[0]}${d.symbols.length > 1 ? ` 等${d.symbols.length}只` : ''}`
|
||||
: base
|
||||
@@ -181,6 +185,14 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
delete d.notify_events
|
||||
if (!d.sector_targets?.length) throw new Error('请选择至少一个监控对象')
|
||||
if ((d.threshold_pct ?? 0) <= 0 || (d.threshold_pct ?? 0) > 20) throw new Error('阈值必须大于 0 且不超过 20%')
|
||||
} else if (d.type === 'abnormal') {
|
||||
delete d.score_min
|
||||
delete d.score_max
|
||||
d.conditions = []
|
||||
delete d.notify_events
|
||||
if ((d.threshold_pct ?? 0) < 1 || (d.threshold_pct ?? 0) > 150) {
|
||||
throw new Error('接近度阈值必须在 1 到 150 之间 (70=边缘, 100=已触发)')
|
||||
}
|
||||
} else {
|
||||
delete d.score_min
|
||||
delete d.score_max
|
||||
@@ -474,8 +486,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 资产类型: 股票 / ETF / 指数 (个股极简模式不显示) */}
|
||||
{!simple && draft.type !== 'sector' && (
|
||||
{/* 资产类型: 股票 / ETF / 指数 (个股极简模式不显示; 板块/异动仅个股) */}
|
||||
{!simple && draft.type !== 'sector' && draft.type !== 'abnormal' && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">资产类型</span>
|
||||
<div className="inline-flex h-9 rounded-btn border border-border overflow-hidden">
|
||||
@@ -510,7 +522,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
{/* 监控类型 */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">监控类型</span>
|
||||
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-5">
|
||||
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-6">
|
||||
{visibleTypes.map(t => {
|
||||
const Icon = TYPE_ICONS[t.key as keyof typeof TYPE_ICONS] ?? Activity
|
||||
const active = draft.type === t.key
|
||||
@@ -527,10 +539,16 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
notify_events: type === 'strategy'
|
||||
? [...(d.notify_events ?? DEFAULT_STRATEGY_NOTIFY_EVENTS)]
|
||||
: undefined,
|
||||
scope: type === 'sector'
|
||||
scope: type === 'sector' || type === 'abnormal'
|
||||
? 'all'
|
||||
: type === 'strategy' && d.scope === 'symbols' && d.symbols.length === 0 ? 'all' : d.scope,
|
||||
direction: type === 'sector' ? 'up' : d.type === 'sector' ? 'entry' : d.direction,
|
||||
direction: type === 'sector' ? 'up'
|
||||
: type === 'abnormal' ? 'both'
|
||||
: d.type === 'sector' || d.type === 'abnormal' ? 'entry' : d.direction,
|
||||
// 异动规则复用 threshold_pct 存接近度阈值%, 其他类型为涨跌幅%
|
||||
threshold_pct: type === 'abnormal' && d.type !== 'abnormal' ? 70
|
||||
: type !== 'abnormal' && d.type === 'abnormal' ? 1
|
||||
: d.threshold_pct,
|
||||
}
|
||||
})}
|
||||
className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-btn border px-2 text-xs font-medium transition-colors cursor-pointer ${
|
||||
@@ -748,6 +766,80 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft.type === 'abnormal' && (
|
||||
<div className="space-y-4 border-t border-border/60 pt-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">接近度阈值</span>
|
||||
<span className="relative block">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="150"
|
||||
step="5"
|
||||
value={draft.threshold_pct ?? 70}
|
||||
onChange={event => setDraft(d => ({ ...d, threshold_pct: Number(event.target.value) }))}
|
||||
className="h-9 w-full rounded-btn border border-border bg-base pl-3 pr-8 text-xs font-mono text-foreground"
|
||||
/>
|
||||
<span className="absolute right-3 top-2.5 text-xs text-muted">%</span>
|
||||
</span>
|
||||
<span className="block text-[10px] text-muted/70">
|
||||
接近度 = |偏离值| ÷ 交易所阈值。70=边缘预警, 100=已触发
|
||||
</span>
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">方向</span>
|
||||
<div className="grid h-9 grid-cols-3 overflow-hidden rounded-btn border border-border bg-base">
|
||||
{([
|
||||
['both', '全部'],
|
||||
['up', '涨势偏离'],
|
||||
['down', '跌势偏离'],
|
||||
] as const).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={(draft.direction ?? 'both') === key}
|
||||
onClick={() => setDraft(d => ({ ...d, direction: key }))}
|
||||
className={`text-[11px] font-medium transition-colors cursor-pointer ${
|
||||
(draft.direction ?? 'both') === key ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">关注窗口</span>
|
||||
<div className="grid h-9 grid-cols-4 overflow-hidden rounded-btn border border-border bg-base">
|
||||
{([
|
||||
['any', '全部'],
|
||||
['3d', '3日 (异常波动)'],
|
||||
['10d', '10日 (严重)'],
|
||||
['30d', '30日 (严重)'],
|
||||
] as const).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={(draft.abnormal_window ?? 'any') === key}
|
||||
onClick={() => setDraft(d => ({ ...d, abnormal_window: key }))}
|
||||
className={`text-[11px] font-medium transition-colors cursor-pointer ${
|
||||
(draft.abnormal_window ?? 'any') === key ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-btn bg-base px-3 py-2 text-[10px] leading-relaxed text-muted">
|
||||
按交易所异动规则口径 (3日±20%/30%… 10日+100%、30日+200% 等按板块) 计算
|
||||
个股涨跌幅偏离值的接近度, 上穿阈值时告警; 冷却期内同一标的不重复提醒。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 作用范围 */}
|
||||
{draft.type !== 'sector' && <div className="space-y-2">
|
||||
<span className="text-[11px] text-muted">作用范围</span>
|
||||
@@ -878,7 +970,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
</div>}
|
||||
|
||||
{/* 触发条件 (非 strategy) */}
|
||||
{draft.type !== 'strategy' && draft.type !== 'sector' && (
|
||||
{draft.type !== 'strategy' && draft.type !== 'sector' && draft.type !== 'abnormal' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-muted">触发条件</span>
|
||||
|
||||
@@ -114,6 +114,9 @@ export function renderBuiltinDataCell(r: any, col: ColumnConfig): ReactNode | nu
|
||||
case 'momentum_20d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.momentum_20d)}`}>{fmtPct(r.momentum_20d)}</td>
|
||||
case 'momentum_30d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.momentum_30d)}`}>{fmtPct(r.momentum_30d)}</td>
|
||||
case 'momentum_60d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.momentum_60d)}`}>{fmtPct(r.momentum_60d)}</td>
|
||||
case 'deviate_3d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.deviate_3d)}`} title="偏离值 = 个股3日涨跌幅 − 对应指数 (主板±20%/创业科创±30%/北交所±40% 触发)">{fmtPct(r.deviate_3d)}</td>
|
||||
case 'deviate_10d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.deviate_10d)}`} title="10日累计偏离 (+100% 触发严重异常波动)">{fmtPct(r.deviate_10d)}</td>
|
||||
case 'deviate_30d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.deviate_30d)}`} title="30日累计偏离 (+200% 触发严重异常波动)">{fmtPct(r.deviate_30d)}</td>
|
||||
// 连板
|
||||
case 'limit_ups':
|
||||
return (
|
||||
|
||||
+52
-1
@@ -735,11 +735,49 @@ export interface SectorMonitorTarget {
|
||||
member_count: number
|
||||
}
|
||||
|
||||
export interface AbnormalWindowInfo {
|
||||
/** 实时偏离值 (小数) */
|
||||
value: number
|
||||
/** 该窗口阈值 (小数) */
|
||||
threshold: number
|
||||
/** 接近度 |value|/threshold */
|
||||
closeness: number
|
||||
}
|
||||
|
||||
export type AbnormalStatus = 'triggered' | 'edge' | 'watch'
|
||||
|
||||
export interface AbnormalRow {
|
||||
symbol: string
|
||||
name: string | null
|
||||
board: string
|
||||
st: boolean
|
||||
close: number | null
|
||||
rt_pct: number | null
|
||||
windows: Record<string, AbnormalWindowInfo>
|
||||
max_closeness: number
|
||||
status: AbnormalStatus
|
||||
}
|
||||
|
||||
export interface AbnormalOverview {
|
||||
asof: number
|
||||
cache_date: string | null
|
||||
bench_rt_pct: number
|
||||
includes_today: boolean
|
||||
rules: Array<{
|
||||
board: string
|
||||
st: boolean
|
||||
thresholds: Record<string, number>
|
||||
note: string
|
||||
}>
|
||||
counts: { triggered: number; edge: number; watch: number }
|
||||
rows: AbnormalRow[]
|
||||
}
|
||||
|
||||
export interface MonitorRule {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
type: 'strategy' | 'signal' | 'price' | 'market' | 'ladder' | 'sector'
|
||||
type: 'strategy' | 'signal' | 'price' | 'market' | 'ladder' | 'sector' | 'abnormal'
|
||||
asset_type?: 'stock' | 'etf' | 'index'
|
||||
scope: 'symbols' | 'all' | 'sector'
|
||||
symbols: string[]
|
||||
@@ -749,6 +787,8 @@ export interface MonitorRule {
|
||||
sector_trigger?: 'change_pct' | 'momentum'
|
||||
threshold_pct?: number
|
||||
window_minutes?: 1 | 3 | 5 | 10 | 15
|
||||
/** abnormal 专属: 关注窗口 (any=全部) */
|
||||
abnormal_window?: 'any' | '3d' | '10d' | '30d'
|
||||
strategy_id?: string | null
|
||||
direction: 'entry' | 'exit' | 'both' | 'up' | 'down'
|
||||
notify_events?: StrategyNotifyEvent[]
|
||||
@@ -817,6 +857,11 @@ export interface AlertEvent {
|
||||
up_count?: number
|
||||
down_count?: number
|
||||
leader?: { symbol?: string; name?: string; change_pct?: number } | null
|
||||
/** 异动边缘告警 (source=abnormal) 附加字段 */
|
||||
abnormal_window?: string
|
||||
abnormal_value?: number
|
||||
abnormal_threshold?: number
|
||||
abnormal_closeness?: number
|
||||
/** ext 富化字段 (行业/概念等), 键为 "{configId}__{fieldName}" */
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -2780,6 +2825,12 @@ export const api = {
|
||||
customSignalDelete: (id: string) =>
|
||||
request<{ ok: boolean }>(`/api/custom-signals/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
// ===== Abnormal Moves (异动边缘) =====
|
||||
abnormalOverview: (minCloseness = 0.5, limit = 200) =>
|
||||
request<AbnormalOverview>(
|
||||
`/api/abnormal/overview?min_closeness=${minCloseness}&limit=${limit}`,
|
||||
),
|
||||
|
||||
// ===== Monitor Rules (监控规则) =====
|
||||
monitorRulesList: () =>
|
||||
request<{ rules: MonitorRule[] }>('/api/monitor-rules'),
|
||||
|
||||
@@ -26,6 +26,8 @@ export const QK = {
|
||||
watchlistGroups: ['watchlist-groups'] as const,
|
||||
watchlistQuotes: ['watchlist-quotes'] as const,
|
||||
watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const,
|
||||
// 异动边缘总览 (开启监控时才查询, 参数为 min_closeness/limit)
|
||||
abnormalOverview: (minCloseness: number, limit: number) => ['abnormal-overview', minCloseness, limit] as const,
|
||||
// 不用 watchlist- 前缀: 日K历史盘中几乎不变, 若被 SSE quotes_updated 高频失效
|
||||
// (expert 1s) 会导致全自选日K每秒重拉, staleTime 形同虚设。
|
||||
// 刷新点: staleTime 过期 + Watchlist 增删自选/改蜡烛天数时的手动失效;
|
||||
|
||||
@@ -65,6 +65,10 @@ export const SCREENER_BUILTIN_COLUMNS: ColumnConfig[] = [
|
||||
{ id: 'builtin:momentum_20d', source: { type: 'builtin', key: 'momentum_20d' }, label: '20D 动量', visible: false, align: 'right' },
|
||||
{ id: 'builtin:momentum_30d', source: { type: 'builtin', key: 'momentum_30d' }, label: '30D 动量', visible: false, align: 'right' },
|
||||
{ id: 'builtin:momentum_60d', source: { type: 'builtin', key: 'momentum_60d' }, label: '60D 动量', visible: true, align: 'right' },
|
||||
// 异动偏离 (交易所异动规则口径, 运行时列; 默认隐藏, 在列组「异动」中开启)
|
||||
{ id: 'builtin:deviate_3d', source: { type: 'builtin', key: 'deviate_3d' }, label: '3D 偏离', visible: false, align: 'right' },
|
||||
{ id: 'builtin:deviate_10d', source: { type: 'builtin', key: 'deviate_10d' }, label: '10D 偏离', visible: false, align: 'right' },
|
||||
{ id: 'builtin:deviate_30d', source: { type: 'builtin', key: 'deviate_30d' }, label: '30D 偏离', visible: false, align: 'right' },
|
||||
// 连板
|
||||
{ id: 'builtin:limit_ups', source: { type: 'builtin', key: 'limit_ups' }, label: '连板', visible: true, align: 'center' },
|
||||
{ id: 'builtin:limit_downs', source: { type: 'builtin', key: 'limit_downs' }, label: '连跌', visible: false, align: 'center' },
|
||||
@@ -93,6 +97,7 @@ export const SCREENER_COLUMN_GROUPS: ColumnGroup[] = [
|
||||
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
|
||||
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
|
||||
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
|
||||
{ id: 'abnormal', label: '异动', icon: '⚡', keys: ['deviate_3d', 'deviate_10d', 'deviate_30d'] },
|
||||
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
|
||||
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle', 'intraday'] },
|
||||
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
|
||||
|
||||
@@ -96,6 +96,9 @@ export function getSortValue(r: any, col: ColumnConfig): any {
|
||||
case 'momentum_20d': return r.momentum_20d
|
||||
case 'momentum_30d': return r.momentum_30d
|
||||
case 'momentum_60d': return r.momentum_60d
|
||||
case 'deviate_3d': return r.deviate_3d
|
||||
case 'deviate_10d': return r.deviate_10d
|
||||
case 'deviate_30d': return r.deviate_30d
|
||||
case 'limit_ups': return r.consecutive_limit_ups ?? 0
|
||||
case 'limit_downs': return r.consecutive_limit_downs ?? 0
|
||||
case 'score': return r.score
|
||||
|
||||
@@ -63,6 +63,12 @@ export const storage = {
|
||||
/** 自选分组统计条配置 (metric: 统计指标, sort: 排序方式, card*: 分组卡片显示项) */
|
||||
watchlistGroupStats: kv<{ metric: string; sort: string; cardTopN?: number; cardColorBar?: boolean; cardRank?: boolean }>('watchlist_groupStats'),
|
||||
|
||||
/** 异动监控: 主开关 (默认关, 开启后才轮询计算; 告警走监控中心规则) */
|
||||
abnormalEnabled: kv<boolean>('abnormal_enabled'),
|
||||
|
||||
/** 异动监控: 上次计算结果 (关闭开关后仍展示, 含 asof 计算时间戳) */
|
||||
abnormalLastResult: kv<unknown>('abnormal_last_result'),
|
||||
|
||||
/** Screener 卡片尺寸 */
|
||||
screenerCardSize: kv<string>('screener-card-size'),
|
||||
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FlaskConical, HelpCircle, History, Power, RefreshCw, Search, Settings2 } from 'lucide-react'
|
||||
import { api, type AbnormalOverview, type AbnormalRow, type AbnormalStatus } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { storage } from '@/lib/storage'
|
||||
import { fmtPrice, fmtPct, priceColorClass } from '@/lib/format'
|
||||
import { boardTag } from '@/components/stock-table/primitives'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
|
||||
/**
|
||||
* 异动监控 — 按交易所异动规则口径 (3日±20%/±30%/±40%, 10日+100%, 30日+200%)
|
||||
* 实时计算个股「偏离值/阈值」接近度, 找出处于异动边缘的标的。
|
||||
*
|
||||
* 计算量可控: 主开关默认关闭, 开启后才发起轮询 (每 60s 一次); 关闭后不再计算,
|
||||
* 但保留展示上次计算结果 (含计算时间, 取自 localStorage)。
|
||||
* 规则口径通过标题栏「?」展开查看。告警走系统监控体系: 在「监控中心」创建
|
||||
* 异动监控规则后由后端持续评估, 统一触发记录/站内通知/飞书·企微推送。
|
||||
*/
|
||||
|
||||
const WINDOW_KEYS = ['3d', '10d', '30d'] as const
|
||||
type WindowKey = (typeof WINDOW_KEYS)[number]
|
||||
|
||||
const WINDOW_LABELS: Record<WindowKey, string> = {
|
||||
'3d': '3日偏离',
|
||||
'10d': '10日偏离',
|
||||
'30d': '30日偏离',
|
||||
}
|
||||
|
||||
const STATUS_META: Record<AbnormalStatus, { label: string; cls: string; bar: string }> = {
|
||||
triggered: { label: '已触发', cls: 'bg-danger/15 text-danger', bar: 'bg-danger' },
|
||||
edge: { label: '异动边缘', cls: 'bg-warning/15 text-warning', bar: 'bg-warning' },
|
||||
watch: { label: '观察', cls: 'bg-elevated text-secondary', bar: 'bg-muted' },
|
||||
}
|
||||
|
||||
const BOARDS = ['主板', '创业板', '科创板', '北交所'] as const
|
||||
|
||||
const REFRESH_MS = 60_000
|
||||
|
||||
export function AbnormalMoves() {
|
||||
// 主开关: 默认关闭, 开启后才轮询计算 (仅控制本页计算, 后台告警由监控规则驱动)
|
||||
const [enabled, setEnabled] = useState(() => storage.abnormalEnabled.get(false))
|
||||
// 规则口径面板 (标题栏「?」)
|
||||
const [rulesOpen, setRulesOpen] = useState(false)
|
||||
// 上次计算结果: 开启时每次成功计算都落本地, 关闭后仍展示
|
||||
const [lastResult, setLastResult] = useState<AbnormalOverview | null>(
|
||||
() => (storage.abnormalLastResult.get(null) as AbnormalOverview | null) ?? null,
|
||||
)
|
||||
const [windowFilter, setWindowFilter] = useState<'all' | WindowKey>('all')
|
||||
const [direction, setDirection] = useState<'both' | 'up' | 'down'>('both')
|
||||
const [boardFilter, setBoardFilter] = useState<'all' | (typeof BOARDS)[number]>('all')
|
||||
const [minCloseness, setMinCloseness] = useState(0.5)
|
||||
const [query, setQuery] = useState('')
|
||||
const [watchlistOnly, setWatchlistOnly] = useState(false)
|
||||
const [preview, setPreview] = useState<{ symbol: string; name: string } | null>(null)
|
||||
|
||||
const overview = useQuery({
|
||||
queryKey: QK.abnormalOverview(minCloseness, 300),
|
||||
queryFn: () => api.abnormalOverview(minCloseness, 300),
|
||||
enabled, // 关闭时零计算
|
||||
refetchInterval: enabled ? REFRESH_MS : false,
|
||||
})
|
||||
// 自选过滤在关闭 (查看上次结果) 时也可用: 自选列表是轻量接口, 不涉及全市场计算
|
||||
const watchlist = useQuery({
|
||||
queryKey: QK.watchlist,
|
||||
queryFn: api.watchlistList,
|
||||
enabled: watchlistOnly,
|
||||
})
|
||||
|
||||
const toggleEnabled = (v: boolean) => {
|
||||
setEnabled(v)
|
||||
storage.abnormalEnabled.set(v)
|
||||
if (v) {
|
||||
overview.refetch()
|
||||
}
|
||||
}
|
||||
|
||||
const data = overview.data
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
setLastResult(data)
|
||||
storage.abnormalLastResult.set(data)
|
||||
}, [data])
|
||||
|
||||
// 展示数据源: 开启 → 实时结果; 关闭 → 上次计算结果 (可能为空)
|
||||
const view = enabled ? data : lastResult
|
||||
const stale = !enabled && lastResult != null
|
||||
|
||||
const watchSymbols = useMemo(() => {
|
||||
const set = new Set((watchlist.data?.symbols ?? []).map(e => e.symbol))
|
||||
return set
|
||||
}, [watchlist.data])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let list = view?.rows ?? []
|
||||
if (windowFilter !== 'all') {
|
||||
list = list.filter(r => {
|
||||
const w = r.windows[windowFilter]
|
||||
return w != null && w.closeness >= minCloseness
|
||||
})
|
||||
}
|
||||
if (direction !== 'both') {
|
||||
list = list.filter(r => {
|
||||
const w = windowFilter !== 'all' ? r.windows[windowFilter] : dominantWindow(r)
|
||||
const v = w?.value ?? 0
|
||||
return direction === 'up' ? v > 0 : v < 0
|
||||
})
|
||||
}
|
||||
if (boardFilter !== 'all') list = list.filter(r => r.board === boardFilter)
|
||||
if (watchlistOnly) list = list.filter(r => watchSymbols.has(r.symbol))
|
||||
const q = query.trim().toLowerCase()
|
||||
if (q) {
|
||||
list = list.filter(r => `${r.symbol} ${r.name ?? ''}`.toLowerCase().includes(q))
|
||||
}
|
||||
return list
|
||||
}, [view, windowFilter, direction, boardFilter, watchlistOnly, watchSymbols, query, minCloseness])
|
||||
|
||||
const counts = view?.counts
|
||||
const updating = overview.isFetching
|
||||
|
||||
return (
|
||||
// 整页占满视口: 头部/筛选固定, 只有表格列表区滚动
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="shrink-0">
|
||||
<PageHeader
|
||||
title="异动监控"
|
||||
subtitle="3日异常波动 / 10日·30日严重异常波动 · 偏离值接近度"
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={rulesOpen}
|
||||
aria-label="查看异动规则口径"
|
||||
title="交易所异动规则口径 (阈值 / 偏离值计算方式)"
|
||||
onClick={() => setRulesOpen(v => !v)}
|
||||
className={`inline-flex h-7 w-7 items-center justify-center rounded border transition-colors ${
|
||||
rulesOpen
|
||||
? 'border-accent/40 bg-accent/10 text-accent'
|
||||
: 'border-border bg-base text-secondary hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{enabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => overview.refetch()}
|
||||
className="inline-flex h-7 items-center gap-1 rounded border border-border bg-base px-2 text-[11px] text-secondary transition-colors hover:text-foreground"
|
||||
title="立即刷新"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${updating ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to="/monitor"
|
||||
className="inline-flex h-7 items-center gap-1 rounded border border-border bg-base px-2 text-[11px] text-secondary transition-colors hover:text-foreground"
|
||||
title="在监控中心创建「异动监控」规则: 后台持续评估, 触发时统一走触发记录/站内通知/飞书·企微推送, 无需保持本页打开"
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
告警规则
|
||||
</Link>
|
||||
{/* 主开关: 开启后才开始轮询计算 */}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label="启用异动监控计算"
|
||||
onClick={() => toggleEnabled(!enabled)}
|
||||
className={`inline-flex h-7 items-center gap-2 rounded border px-2.5 text-[11px] font-medium transition-colors ${
|
||||
enabled
|
||||
? 'border-accent/40 bg-accent/12 text-accent'
|
||||
: 'border-border bg-base text-secondary hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Power className="h-3 w-3" />
|
||||
{enabled ? '监控中 · 每60秒计算' : '开启监控'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 px-5 py-4">
|
||||
|
||||
{/* 规则口径面板 (标题栏「?」展开) */}
|
||||
{rulesOpen && (
|
||||
<div className="shrink-0 rounded-card border border-border bg-surface p-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{ruleChips()}
|
||||
</div>
|
||||
<p className="mt-2.5 border-t border-border/60 pt-2 text-[10px] leading-relaxed text-muted">
|
||||
口径说明: 偏离值 = 个股 N 日累计涨跌幅 − 对应指数同期涨跌幅 (沪: 上证A指/上证指数,
|
||||
深: 深证A指/深证成指, 北: 北证50)。阈值为交易所异常波动披露标准的近似值, 仅供风险提示,
|
||||
不构成监管认定。每只股票在 3日/10日/30日 三档各算一个接近度 (|偏离值| ÷ 该档阈值,
|
||||
阈值随板块与 ST 身份不同), 表格「接近度」列与状态取三档中的最高值,
|
||||
来源窗口的偏离值颜色加重显示、其余窗口淡化; ≥100% 已触发、≥70% 边缘、≥50% 观察。
|
||||
偏离列亦可在自选/选股的「异动」列组中启用, 并可作为监控规则与自定义信号的阈值字段。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 未开启且无历史结果: 说明 + 开启入口 (有上次结果时直接展示数据, 见下方 stale 横幅) */}
|
||||
{!enabled && !stale ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div className="m-auto rounded-card border border-border bg-surface p-8 text-center">
|
||||
<FlaskConical className="mx-auto h-8 w-8 text-muted/50" />
|
||||
<div className="mt-3 text-sm font-medium text-foreground">监控未开启</div>
|
||||
<p className="mx-auto mt-2 max-w-lg text-xs leading-relaxed text-muted">
|
||||
开启后按交易所异动规则实时计算全市场个股的涨跌幅偏离值 (个股 N 日累计涨跌 −
|
||||
对应指数同期), 找出接近触发「异常波动 / 严重异常波动」的标的。
|
||||
计算量较大, 默认关闭; 每次计算的结果会保留, 关闭后仍可查看 (不再实时更新)。
|
||||
</p>
|
||||
<p className="mx-auto mt-2 max-w-lg text-[11px] leading-relaxed text-muted/80">
|
||||
需要告警推送时, 在<Link to="/monitor?new=abnormal" className="text-accent hover:underline">监控中心</Link>
|
||||
新建「异动监控」规则 —— 后台持续评估, 触发时统一走触发记录 / 站内通知 / 飞书·企微推送,
|
||||
与本页开关互不影响。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEnabled(true)}
|
||||
className="mt-5 inline-flex h-9 items-center gap-2 rounded-btn bg-accent px-4 text-xs font-medium text-base"
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
开启监控
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 关闭后展示上次计算结果 */}
|
||||
{stale && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 rounded-card border border-warning/25 bg-warning/5 px-3 py-2">
|
||||
<History className="h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
<span className="text-[11px] font-medium text-warning">已暂停计算 · 展示上次结果</span>
|
||||
<span className="text-[11px] text-secondary">
|
||||
上次计算 {fmtCalcTime(lastResult.asof)} · 数据截至 {lastResult.cache_date ?? '—'}
|
||||
{lastResult.includes_today ? ' (含今日收盘)' : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEnabled(true)}
|
||||
className="ml-auto inline-flex h-7 shrink-0 items-center gap-1 rounded border border-accent/40 bg-accent/10 px-2.5 text-[11px] font-medium text-accent transition-colors hover:bg-accent/15"
|
||||
>
|
||||
<Power className="h-3 w-3" />
|
||||
开启实时计算
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统计 + 筛选 */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<StatusChip label="已触发" count={counts?.triggered} tone="danger" />
|
||||
<StatusChip label="异动边缘" count={counts?.edge} tone="warning" />
|
||||
<StatusChip label="观察" count={counts?.watch} tone="muted" />
|
||||
{enabled && (
|
||||
<span className="text-[10px] text-muted">
|
||||
数据截至 {data?.cache_date ?? '—'}
|
||||
{data?.includes_today ? ' (含今日收盘)' : ' · 已叠加今日实时涨跌'}
|
||||
{data ? ` · 基准指数今日 ${(data.bench_rt_pct * 100).toFixed(2)}%` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<SegmentedControl
|
||||
value={windowFilter}
|
||||
onChange={v => setWindowFilter(v)}
|
||||
options={[
|
||||
{ value: 'all', label: '全部窗口' },
|
||||
...WINDOW_KEYS.map(w => ({ value: w, label: WINDOW_LABELS[w] })),
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={direction}
|
||||
onChange={v => setDirection(v)}
|
||||
options={[
|
||||
{ value: 'both', label: '双向' },
|
||||
{ value: 'up', label: '正向' },
|
||||
{ value: 'down', label: '负向' },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={boardFilter}
|
||||
onChange={v => setBoardFilter(v)}
|
||||
options={[
|
||||
{ value: 'all' as const, label: '全板块' },
|
||||
...BOARDS.map(b => ({ value: b, label: b })),
|
||||
]}
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-secondary" title="只看自选列表中的标的">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={watchlistOnly}
|
||||
onChange={e => setWatchlistOnly(e.target.checked)}
|
||||
className="h-3 w-3 accent-accent"
|
||||
/>
|
||||
只看自选
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-secondary" title="接近度下限 (|偏离|/阈值)">
|
||||
接近度 ≥ {(minCloseness * 100).toFixed(0)}%
|
||||
<input
|
||||
type="range"
|
||||
min={30}
|
||||
max={100}
|
||||
step={5}
|
||||
value={minCloseness * 100}
|
||||
onChange={e => setMinCloseness(Number(e.target.value) / 100)}
|
||||
className="h-1 w-24 accent-accent"
|
||||
/>
|
||||
</label>
|
||||
<div className="relative ml-auto">
|
||||
<Search className="absolute left-2 top-1.5 h-3.5 w-3.5 text-muted" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="搜索代码/名称"
|
||||
className="h-7 w-40 rounded border border-border bg-base pl-7 pr-2 text-[11px] text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主表: 剩余空间内滚动 (页面本身不滚动) */}
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-card border border-border bg-surface">
|
||||
<table className="w-full min-w-[860px] text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-surface">
|
||||
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted">
|
||||
<th className="w-10 px-2 py-2 text-right">#</th>
|
||||
<th className="px-2 py-2 text-left">代码 / 名称</th>
|
||||
<th className="px-2 py-2 text-right">现价</th>
|
||||
<th className="px-2 py-2 text-right">今日</th>
|
||||
{WINDOW_KEYS.map(w => (
|
||||
<th key={w} className="px-2 py-2 text-right">
|
||||
{WINDOW_LABELS[w]}
|
||||
<span className="ml-1 normal-case text-muted/60">(阈值)</span>
|
||||
</th>
|
||||
))}
|
||||
<th
|
||||
className="w-36 px-2 py-2 text-left"
|
||||
title="取 3日/10日/30日 三档中最高的 |偏离值|÷对应档阈值; ≥100% 已触发, ≥70% 边缘, ≥50% 观察"
|
||||
>
|
||||
接近度
|
||||
<span className="ml-1 normal-case text-muted/60">(最高档)</span>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-center">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{overview.isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-3 py-10 text-center text-muted">
|
||||
正在计算全市场偏离值…
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-3 py-10 text-center text-muted">
|
||||
{view ? '当前没有满足条件的标的' : '暂无数据'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((r, i) => (
|
||||
<AbnormalRowView
|
||||
key={r.symbol}
|
||||
row={r}
|
||||
rank={i + 1}
|
||||
onPreview={() => setPreview({ symbol: r.symbol, name: r.name ?? r.symbol })}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
{preview && (
|
||||
<StockPreviewDialog
|
||||
symbol={preview.symbol}
|
||||
name={preview.name}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
function ruleChips() {
|
||||
return (view?.rules ?? FALLBACK_RULES).map((rule, i) => {
|
||||
const thr = WINDOW_KEYS.map(w => `${w.replace('d', '日')}±${fmtThreshold(rule.thresholds[w])}`).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">
|
||||
{rule.board}
|
||||
{rule.st && <span className="ml-1 text-danger">ST</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-[10px] text-muted">{thr}</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 上次计算时间 (服务端 asof 秒级时间戳 → 本地日期时间) */
|
||||
function fmtCalcTime(asofSec: number): string {
|
||||
const d = new Date(asofSec * 1000)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function fmtThreshold(v: number | undefined): string {
|
||||
if (v == null) return '—'
|
||||
return `${(v * 100).toFixed(0)}%`
|
||||
}
|
||||
|
||||
/** 全窗口里接近度最高的窗口 */
|
||||
function dominantWindow(r: AbnormalRow): { key: WindowKey; value: number; threshold: number; closeness: number } | undefined {
|
||||
let best: { key: WindowKey; value: number; threshold: number; closeness: number } | undefined
|
||||
for (const w of WINDOW_KEYS) {
|
||||
const info = r.windows[w]
|
||||
if (info && (!best || info.closeness > best.closeness)) best = { key: w, ...info }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function AbnormalRowView({ row, rank, onPreview }: {
|
||||
row: AbnormalRow
|
||||
rank: number
|
||||
onPreview: () => void
|
||||
}) {
|
||||
const board = boardTag(row.symbol)
|
||||
const dominant = dominantWindow(row)
|
||||
const meta = STATUS_META[row.status]
|
||||
return (
|
||||
<tr className="group border-b border-border/40 transition-colors last:border-0 hover:bg-elevated/50">
|
||||
<td className="px-2 py-1.5 text-right font-mono text-[10px] text-muted/70">{rank}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
{/* 仅代码/名称可点击打开详情 (与自选列表一致), 其余单元格不可点 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPreview}
|
||||
title="查看个股详情"
|
||||
className="flex min-w-0 items-center gap-1.5 text-left"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-xs text-foreground group-hover:text-accent transition-colors duration-150">{row.symbol}</span>
|
||||
<span className="min-w-0 max-w-40 truncate text-xs text-secondary group-hover:text-foreground transition-colors duration-150">{row.name ?? '—'}</span>
|
||||
{board && (
|
||||
<span className={`shrink-0 rounded px-1 text-[9px] font-bold leading-tight border ${board.color}`}>
|
||||
{board.label}
|
||||
</span>
|
||||
)}
|
||||
{row.st && (
|
||||
<span className="shrink-0 rounded border border-danger/30 bg-danger/10 px-1 text-[9px] font-bold text-danger">
|
||||
ST
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono text-xs text-secondary">{fmtPrice(row.close)}</td>
|
||||
<td className={`px-2 py-1.5 text-right font-mono text-xs font-medium ${priceColorClass(row.rt_pct)}`}>
|
||||
{fmtPct(row.rt_pct)}
|
||||
</td>
|
||||
{WINDOW_KEYS.map(w => {
|
||||
const info = row.windows[w]
|
||||
// 接近度取最高档: 来源窗口颜色加重 (加粗), 其余窗口淡化, 以此区分「哪一档」
|
||||
const isDominant = dominant?.key === w
|
||||
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 ? ' · 本行接近度来源' : ''}`}
|
||||
>
|
||||
{fmtPct(info.value)}
|
||||
<span className="ml-1 text-[9px] text-muted/60">/{fmtThreshold(info.threshold)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted/40">—</span>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className="px-2 py-1.5">
|
||||
<div
|
||||
className="flex items-center gap-1.5"
|
||||
title="取 3日/10日/30日 三档中最高的 |偏离值|÷对应档阈值; ≥100% 已触发, ≥70% 边缘, ≥50% 观察"
|
||||
>
|
||||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-elevated">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${meta.bar}`}
|
||||
style={{ width: `${Math.min(100, (dominant?.closeness ?? 0) * 100)}%}` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] tabular-nums text-secondary">
|
||||
{((dominant?.closeness ?? 0) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${meta.cls}`}>{meta.label}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusChip({ label, count, tone }: { label: string; count?: number; tone: 'danger' | 'warning' | 'muted' }) {
|
||||
const toneCls =
|
||||
tone === 'danger'
|
||||
? 'border-danger/30 bg-danger/8 text-danger'
|
||||
: tone === 'warning'
|
||||
? 'border-warning/30 bg-warning/8 text-warning'
|
||||
: 'border-border bg-elevated text-secondary'
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] ${toneCls}`}>
|
||||
<span className="font-mono text-sm font-semibold tabular-nums">{count ?? '—'}</span>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({ value, onChange, options }: {
|
||||
value: T
|
||||
onChange: (v: T) => void
|
||||
options: Array<{ value: T; label: string }>
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex h-7 overflow-hidden rounded border border-border bg-base">
|
||||
{options.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
aria-pressed={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={`px-2.5 text-[11px] transition-colors ${
|
||||
value === o.value ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 后端数据未到时的规则表兜底 (与后端 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: '' },
|
||||
]
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { AlertTriangle, RadioTower, Plus, Trash2, Settings2, Zap, Bell, ListChecks, BellRing, TrendingUp, TrendingDown, Flame, Tags } from 'lucide-react'
|
||||
@@ -22,6 +22,7 @@ import { usePreferences } from '@/lib/useSharedQueries'
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
signal: '信号', price: '价格/涨跌', market: '市场异动', strategy: '策略监控', sector: '板块监控',
|
||||
abnormal: '异动监控',
|
||||
}
|
||||
|
||||
/** 严重级别 → 左侧色条 + 图标 */
|
||||
@@ -36,6 +37,7 @@ const SOURCE_BADGE_STYLE: Record<string, string> = {
|
||||
price: 'bg-emerald-400/10 text-emerald-400 border-emerald-400/20',
|
||||
market: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
|
||||
sector: 'bg-cyan-500/10 text-cyan-700 border-cyan-500/20 dark:text-cyan-300',
|
||||
abnormal: 'bg-orange-500/10 text-orange-500 border-orange-500/20 dark:text-orange-400',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,9 +116,22 @@ export function Monitor() {
|
||||
const qc = useQueryClient()
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editingRule, setEditingRule] = useState<MonitorRule | null>(null)
|
||||
const [editorPreset, setEditorPreset] = useState<Partial<MonitorRule> | null>(null)
|
||||
|
||||
// 深链: /monitor?new=abnormal (异动监控页「告警规则」入口) → 直接弹出预置类型的编辑器
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
useEffect(() => {
|
||||
const kind = searchParams.get('new')
|
||||
if (kind === 'abnormal') {
|
||||
setEditingRule(null)
|
||||
setEditorPreset({ type: 'abnormal', threshold_pct: 70, direction: 'both', abnormal_window: 'any', scope: 'all' })
|
||||
setEditorOpen(true)
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
// 触发记录: 过滤 + 统计 (提升到主组件, 供 header 行使用)
|
||||
const [filter, setFilter] = useState<'all' | 'strategy' | 'signal' | 'price' | 'market' | 'sector'>('all')
|
||||
const [filter, setFilter] = useState<'all' | 'strategy' | 'signal' | 'price' | 'market' | 'sector' | 'abnormal'>('all')
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
const [confirmClearRules, setConfirmClearRules] = useState(false)
|
||||
|
||||
@@ -177,7 +192,7 @@ export function Monitor() {
|
||||
<SectionHeader icon={BellRing} title="触发记录" />
|
||||
{/* 过滤标签 */}
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{(['all', 'strategy', 'signal', 'price', 'market', 'sector'] as const).map(f => (
|
||||
{(['all', 'strategy', 'signal', 'price', 'market', 'sector', 'abnormal'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
@@ -225,7 +240,7 @@ export function Monitor() {
|
||||
<span className="rounded-md bg-elevated/50 px-1.5 py-0.5 text-[10px] font-medium text-muted">{rulesCount}</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => { setEditingRule(null); setEditorOpen(true) }}
|
||||
onClick={() => { setEditingRule(null); setEditorPreset(null); setEditorOpen(true) }}
|
||||
title="新建规则"
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-lg border border-border/60 bg-surface text-muted transition-all hover:border-accent/40 hover:text-accent hover:shadow-sm cursor-pointer"
|
||||
>
|
||||
@@ -254,7 +269,8 @@ export function Monitor() {
|
||||
<RuleEditorDialog
|
||||
open={editorOpen}
|
||||
rule={editingRule}
|
||||
onClose={() => { setEditorOpen(false); setEditingRule(null) }}
|
||||
preset={editorPreset}
|
||||
onClose={() => { setEditorOpen(false); setEditingRule(null); setEditorPreset(null) }}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -778,6 +794,18 @@ function RulesList({ rulesQuery, onEdit }: {
|
||||
{r.direction === 'down' ? ' ≤ -' : ' ≥ '}{r.threshold_pct ?? 1}%
|
||||
</span>
|
||||
</div>
|
||||
) : r.type === 'abnormal' ? (
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1 pl-0.5">
|
||||
<span className="rounded bg-orange-500/8 px-1.5 py-0.5 text-[9px] text-orange-500 dark:text-orange-400">
|
||||
接近度 ≥ {r.threshold_pct ?? 70}%
|
||||
</span>
|
||||
<span className="rounded bg-elevated px-1.5 py-0.5 text-[9px] text-secondary">
|
||||
{r.abnormal_window && r.abnormal_window !== 'any' ? `${r.abnormal_window.toUpperCase()} 窗口` : '全部窗口'}
|
||||
</span>
|
||||
<span className="rounded bg-elevated px-1.5 py-0.5 text-[9px] text-secondary">
|
||||
{r.direction === 'up' ? '涨势偏离' : r.direction === 'down' ? '跌势偏离' : '涨跌双向'}
|
||||
</span>
|
||||
</div>
|
||||
) : r.type === 'strategy' && r.strategy_id ? (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 pl-0.5">
|
||||
{(r.score_min != null || r.score_max != null) && (
|
||||
@@ -827,7 +855,12 @@ function RulesList({ rulesQuery, onEdit }: {
|
||||
}
|
||||
|
||||
// ── 规则编辑对话框 ────────────────────────────────────
|
||||
function RuleEditorDialog({ open, rule, onClose }: { open: boolean; rule: MonitorRule | null; onClose: () => void }) {
|
||||
function RuleEditorDialog({ open, rule, preset, onClose }: {
|
||||
open: boolean
|
||||
rule: MonitorRule | null
|
||||
preset?: Partial<MonitorRule> | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -849,6 +882,7 @@ function RuleEditorDialog({ open, rule, onClose }: { open: boolean; rule: Monito
|
||||
>
|
||||
<RuleEditor
|
||||
rule={rule}
|
||||
preset={preset ?? undefined}
|
||||
onClose={onClose}
|
||||
onSaved={onClose}
|
||||
/>
|
||||
|
||||
@@ -41,6 +41,7 @@ const BUILTIN_PAGES: NavEntry[] = [
|
||||
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
|
||||
{ id: '/stock-analysis', label: '个股分析', type: 'builtin', visible: true },
|
||||
{ id: '/regime', label: '市场环境', type: 'builtin', visible: true },
|
||||
{ id: '/abnormal', label: '异动监控', type: 'builtin', visible: true },
|
||||
{ id: '/review', label: '复盘', type: 'builtin', visible: true },
|
||||
{ id: '/financials', label: '财务分析', type: 'builtin', visible: true },
|
||||
{ id: '/indices', label: '指数', type: 'builtin', visible: true },
|
||||
|
||||
@@ -33,6 +33,7 @@ const Branding = lazy(() => import('./pages/Branding').then(m => ({ default: m.B
|
||||
const Settings = lazy(() => import('./pages/Settings').then(m => ({ default: m.Settings })))
|
||||
const Indices = lazy(() => import('./pages/Indices').then(m => ({ default: m.Indices })))
|
||||
const Regime = lazy(() => import('./pages/Regime').then(m => ({ default: m.Regime })))
|
||||
const AbnormalMoves = lazy(() => import('./pages/AbnormalMoves').then(m => ({ default: m.AbnormalMoves })))
|
||||
const Dev = lazy(() => import('./pages/Dev').then(m => ({ default: m.Dev })))
|
||||
|
||||
const CORE_ROUTE_PATHS = new Set([
|
||||
@@ -56,6 +57,7 @@ const CORE_ROUTE_PATHS = new Set([
|
||||
'/limit-ladder',
|
||||
'/indices',
|
||||
'/regime',
|
||||
'/abnormal',
|
||||
'/branding',
|
||||
'/settings',
|
||||
'/dev',
|
||||
@@ -128,6 +130,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'limit-ladder', element: <LimitUpLadder /> },
|
||||
{ path: 'indices', element: <Indices /> },
|
||||
{ path: 'regime', element: <Regime /> },
|
||||
{ path: 'abnormal', element: <AbnormalMoves /> },
|
||||
{ path: 'branding', element: <Branding /> },
|
||||
{ path: 'settings', element: <Settings /> },
|
||||
// 隐藏路由:开发者工具(不暴露在菜单,仅供调试)
|
||||
|
||||
Reference in New Issue
Block a user