"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件。 职责: 接收实时行情 DataFrame → 检查监控中策略的信号/提醒 → 推送告警。 不知道: 策略加载逻辑、AI、API、配置持久化、回测。 依赖: 外部调用 on_quote_update() 传入实时数据。 本模块含两个评估器: 1. StrategyMonitorService — 旧的策略监控 (type=strategy),第二步迁移到 MonitorRuleEngine 2. MonitorRuleEngine — 通用规则引擎,覆盖 signal/price/market/strategy 四类, 支持 scope (symbols/all/sector) + 多条件 AND/OR + cooldown 去重 """ from __future__ import annotations import datetime as _dt import logging import time from dataclasses import dataclass, field from typing import Any, Callable import polars as pl from app.strategy.custom_signals import _OP_BUILDERS # type: ignore # 复用运算符构造器 from app.strategy import config as _strategy_config logger = logging.getLogger(__name__) # 信号 / 字段中文名映射 — 与前端 lib/signals.ts 对齐, 用于告警 message / 推送文案。 # signal_* 为内置原子信号, 其余为技术指标/行情字段。 _SIGNAL_CN: dict[str, str] = { # 内置信号 "signal_ma_golden_5_20": "MA5上穿MA20", "signal_ma_dead_5_20": "MA5下穿MA20", "signal_ma_golden_20_60": "MA20上穿MA60", "signal_macd_golden": "MACD金叉", "signal_macd_dead": "MACD死叉", "signal_ma20_breakout": "突破MA20", "signal_ma20_breakdown": "跌破MA20", "signal_n_day_high": "60日新高", "signal_n_day_low": "60日新低", "signal_boll_breakout_upper": "突破布林上轨", "signal_boll_breakdown_lower": "跌破布林下轨", "signal_volume_surge": "放量", "signal_limit_up": "涨停", "signal_limit_down": "跌停", "signal_limit_down_recovery": "跌停翘板", "signal_broken_limit_up": "炸板", # 行情字段 "close": "收盘价", "open": "开盘价", "high": "最高价", "low": "最低价", "change_pct": "涨跌幅", "change_amount": "涨跌额", "amplitude": "振幅", "turnover_rate": "换手率", "volume": "成交量", "amount": "成交额", # 均线 "ma5": "MA5", "ma10": "MA10", "ma20": "MA20", "ma30": "MA30", "ma60": "MA60", "ema5": "EMA5", "ema10": "EMA10", "ema20": "EMA20", # MACD / BOLL / KDJ / RSI "macd_dif": "MACD-DIF", "macd_dea": "MACD-DEA", "macd_hist": "MACD柱", "boll_upper": "布林上轨", "boll_lower": "布林下轨", "kdj_k": "KDJ-K", "kdj_d": "KDJ-D", "kdj_j": "KDJ-J", "rsi_6": "RSI6", "rsi_14": "RSI14", "rsi_24": "RSI24", # 量能 / 动量 / 波动 "vol_ratio_5d": "5日量比", "vol_ratio_20d": "20日量比", "vol_ma5": "5日均量", "vol_ma10": "10日均量", "high_60d": "60日最高", "low_60d": "60日最低", "momentum_5d": "5日动量", "momentum_20d": "20日动量", "momentum_60d": "60日动量", "atr_14": "ATR14", "annual_vol_20d": "20日年化波动", "consecutive_limit_ups": "连板数", "consecutive_limit_downs": "跌停连板", } def _signal_cn_name(name: str) -> str: """返回信号/字段的中文名, 找不到原样返回 (与前端 cnSignal 对齐)。""" return _SIGNAL_CN.get(name, name) @dataclass class StrategyAlert: """策略告警""" type: str # "entry" | "exit" | "alert" strategy_id: str symbol: str name: str | None message: str price: float | None = None change_pct: float | None = None signals: list[str] = field(default_factory=list) class StrategyMonitorService: """策略实时监控服务""" def __init__(self, alert_handler: Callable[[StrategyAlert], None] | None = None): """ Args: alert_handler: 告警回调 (如推 SSE) """ self._alert_handler = alert_handler # strategy_id → 监控配置 self._watching: dict[str, dict] = {} def start(self, strategy_id: str, config: dict) -> None: """开始监控一个策略 config: { "entry_signals": ["signal_n_day_high", ...], "exit_signals": ["signal_ma20_breakdown", ...], "alerts": [{"field": "rsi_14", "op": ">", "value": 80, "message": "..."}], } """ self._watching[strategy_id] = config logger.info("strategy monitor started: %s", strategy_id) def stop(self, strategy_id: str) -> None: self._watching.pop(strategy_id, None) logger.info("strategy monitor stopped: %s", strategy_id) def stop_all(self) -> None: self._watching.clear() @property def watching(self) -> dict[str, dict]: return dict(self._watching) def on_quote_update(self, df: pl.DataFrame) -> list[StrategyAlert]: """行情更新后调用。向量化检查所有监控策略。 Args: df: 实时 enriched 数据 (~5500行) Returns: 触发的告警列表 """ if not self._watching or df.is_empty(): return [] all_alerts: list[StrategyAlert] = [] for strategy_id, cfg in self._watching.items(): # 买入信号 entry_sigs = cfg.get("entry_signals", []) if entry_sigs: for sym, name, price, pct, hit_sigs in self._check_signals(df, entry_sigs): alert = StrategyAlert( type="entry", strategy_id=strategy_id, symbol=sym, name=name, message=f"买入信号触发", price=price, change_pct=pct, signals=hit_sigs, ) all_alerts.append(alert) self._emit(alert) # 卖出信号 exit_sigs = cfg.get("exit_signals", []) if exit_sigs: for sym, name, price, pct, hit_sigs in self._check_signals(df, exit_sigs): alert = StrategyAlert( type="exit", strategy_id=strategy_id, symbol=sym, name=name, message=f"卖出信号触发", price=price, change_pct=pct, signals=hit_sigs, ) all_alerts.append(alert) self._emit(alert) # 提醒条件 for alert_cfg in cfg.get("alerts", []): for sym, name, price, pct in self._check_alert(df, alert_cfg): alert = StrategyAlert( type="alert", strategy_id=strategy_id, symbol=sym, name=name, message=alert_cfg.get("message", "提醒"), price=price, change_pct=pct, ) all_alerts.append(alert) self._emit(alert) return all_alerts def _emit(self, alert: StrategyAlert) -> None: if self._alert_handler: try: self._alert_handler(alert) except Exception as e: logger.warning("alert handler failed: %s", e) @staticmethod def _check_signals( df: pl.DataFrame, signals: list[str], ) -> list[tuple[str, str | None, float | None, float | None, list[str]]]: """检查信号列,返回 [(symbol, name, price, change_pct, [hit_signals])]。 支持内置 signal_ 与自定义 csg_ 前缀。""" cols = set(df.columns) resolved: list[tuple[str, str]] = [] # (原值, 列名) for s in signals: col = s if (s.startswith("signal_") or s.startswith("csg_")) else f"signal_{s}" if col in cols: resolved.append((s, col)) if not resolved: return [] mask = pl.any_horizontal(pl.col(c).fill_null(False) for _, c in resolved) hit_df = df.filter(mask) results = [] for row in hit_df.iter_rows(named=True): sym = row.get("symbol", "") name = row.get("name") price = row.get("close") pct = row.get("change_pct") hit_sigs = [orig for orig, col in resolved if row.get(col)] results.append((sym, name, price, pct, hit_sigs)) return results @staticmethod def _check_alert( df: pl.DataFrame, alert: dict, ) -> list[tuple[str, str | None, float | None, float | None]]: """检查阈值型提醒条件""" field = alert.get("field", "") if field not in df.columns: return [] if "op" in alert: # 阈值比较 op = alert["op"] value = alert["value"] col = pl.col(field) ops = { ">": col > value, ">=": col >= value, "<": col < value, "<=": col <= value, } expr = ops.get(op) if expr is None: return [] else: # 信号列 (布尔) expr = pl.col(field).fill_null(False) hit_df = df.filter(expr) results = [] for row in hit_df.iter_rows(named=True): results.append(( row.get("symbol", ""), row.get("name"), row.get("close"), row.get("change_pct"), )) return results # ================================================================ # 通用监控规则引擎 MonitorRuleEngine # ================================================================ _SIGNAL_PREFIXES = ("signal_", "csg_") def _is_signal_field(field: str) -> bool: return any(field.startswith(p) for p in _SIGNAL_PREFIXES) def _build_condition_mask(df: pl.DataFrame, conditions: list[dict], logic: str) -> pl.DataFrame: """根据 conditions + logic 构建过滤后的命中 DataFrame。 conditions: [{"field","op","value"?}] — op=truth 为布尔信号, 否则阈值比较 logic: "and" | "or" 返回命中行 (含 symbol/name/close/change_pct + 各信号列) """ cols = set(df.columns) parts: list[pl.Expr] = [] for c in conditions: field = c["field"] if field not in cols: return df.head(0) # 字段缺失,无法判定 → 空结果 op = c["op"] if op == "truth": parts.append(pl.col(field).fill_null(False)) elif op in _OP_BUILDERS: parts.append(_OP_BUILDERS[op](pl.col(field), c["value"])) else: return df.head(0) if not parts: return df.head(0) if logic == "or": mask = pl.any_horizontal(parts) else: mask = pl.all_horizontal(parts) return df.filter(mask) class MonitorRuleEngine: """通用监控规则引擎 — 接收实时行情 DataFrame,评估所有规则,返回 AlertEvent。 与 StrategyMonitorService 的区别: - 规则来自 monitor_rules 存储 (用户可配), 而非写死的 strategy config - 支持 scope (symbols/all/sector) 过滤作用域 - 支持 conditions + logic (AND/OR) 任意组合 - ★ cooldown 去重: 同一 (rule_id, symbol) 在冷却期内不重复触发 """ def __init__(self, alert_handler: Callable[[dict], None] | None = None): self._alert_handler = alert_handler self._rules: dict[str, dict] = {} # rule_id → rule # (rule_id, symbol) → 上次触发时间戳(秒)。用于 cooldown 去重。 self._last_fire: dict[tuple[str, str], float] = {} self._strategy_engine = None # 延迟注入, type=strategy 规则用它跑选股 # symbol → 股票名 (enriched DataFrame 已 drop name 列, 触发时从此映射回填) self._name_map: dict[str, str] = {} # 策略选股池状态: strategy_id → 上期选股符号集合 (用于 diff 变更) self._strategy_pools: dict[str, set[str]] = {} # 数据目录 (用于加载策略 overrides) self._data_dir = None # 历史窗口加载器: (target_date, lookback_days) → 多日 enriched DataFrame。 # 用于声明 filter_history 的策略 (如反包), 实时监控时拼历史窗口 + 今日行情跑选股。 # 为 None 时, filter_history 策略仍会被跳过 (保持旧行为, 不破坏无历史场景)。 self._history_loader: Callable[[_dt.date, int], "pl.DataFrame"] | None = None # 本轮 evaluate() 产出的策略选股结果: strategy_id → {rows, total, as_of} # 供策略页实时回显复用 (/api/screener/cached 端点直接读取此内存结果), 避免重跑 self._latest_strategy_results: dict[str, dict] = {} def set_strategy_engine(self, engine) -> None: """注入 StrategyEngine, type=strategy 规则据此跑选股。""" self._strategy_engine = engine def set_data_dir(self, data_dir) -> None: """注入数据目录, 用于加载策略的用户覆盖配置。""" self._data_dir = data_dir def set_history_loader(self, fn) -> None: """注入历史窗口加载器, 用于声明 filter_history 的策略跑实时监控。 loader 签名: (target_date, lookback_days) → 多日 enriched DataFrame。 复用 ScreenerService._load_enriched_history (三级缓存, 命中 ~0ms)。 为 None 时 filter_history 策略退回到跳过逻辑 (不破坏无历史场景)。 """ self._history_loader = fn def set_name_map(self, name_map: dict[str, str]) -> None: """注入 symbol → 股票名 映射, 用于在告警事件里回填 name 字段。 enriched DataFrame 在 pipeline 计算后不含 name 列 (见 indicators/pipeline.py), 触发时从 instruments 表预构建此映射, 保证 AlertEvent.name 有值。 """ self._name_map = name_map or {} # ── 规则管理 ─────────────────────────────────────── def set_rules(self, rules: list[dict]) -> None: """批量设置规则 (覆盖)。用于启动时 reload。""" self._rules = {} for r in rules: if r.get("enabled") is not False: self._rules[r["id"]] = r logger.info("MonitorRuleEngine: 装载 %d 条规则", len(self._rules)) def add_rule(self, rule: dict) -> None: if rule.get("enabled") is not False: self._rules[rule["id"]] = rule else: self._rules.pop(rule["id"], None) def remove_rule(self, rule_id: str) -> None: self._rules.pop(rule_id, None) # 清理对应的 cooldown 记录 self._last_fire = {k: v for k, v in self._last_fire.items() if k[0] != rule_id} def clear(self) -> None: self._rules.clear() self._last_fire.clear() @property def rules(self) -> dict[str, dict]: return dict(self._rules) @property def rule_count(self) -> int: return len(self._rules) def latest_strategy_results(self) -> dict[str, dict]: """返回本轮 evaluate() 产出的策略选股结果 (strategy_id → {rows, total, as_of})。 供策略页实时回显复用: /api/screener/cached 端点直接读取此内存结果, 避免对被监控的策略重跑第二遍。无 type=strategy 规则时返回空 dict。 """ return self._latest_strategy_results def has_rule_type(self, rtype: str) -> bool: """是否存在指定类型的 (已启用) 规则。供 quote_service 判断是否需要注入特殊数据。""" if not self._rules: return False return any( r.get("enabled", True) and r.get("type") == rtype for r in self._rules.values() ) # ── 评估 ─────────────────────────────────────────── def evaluate(self, df: pl.DataFrame) -> list[dict]: """行情更新后评估所有规则。 Args: df: 实时 enriched 数据 (~5500行, 含 signal_/csg_/指标列) Returns: 触发的 AlertEvent dict 列表 (含 ts/rule_id/source/type/symbol/...) """ if not self._rules or df.is_empty(): return [] now = time.time() events: list[dict] = [] # 每轮重置: 只保留本次 evaluate 产出的策略结果 self._latest_strategy_results = {} for rule_id, rule in self._rules.items(): try: events.extend(self._evaluate_rule(df, rule, now)) except Exception as e: logger.warning("规则评估失败 %s: %s", rule_id, e) return events def _evaluate_rule(self, df: pl.DataFrame, rule: dict, now: float) -> list[dict]: """评估单条规则,返回触发的 events。""" # 1. 按 scope 过滤作用域 scoped = self._apply_scope(df, rule) if scoped.is_empty(): return [] # 2. 根据 type 构建命中集 # 元组格式: (event_type, symbol, name, price, pct, signals) hit_rows: list[tuple[str, str, Any, Any, Any, list[str]]] = [] rtype = rule.get("type", "signal") if rtype == "strategy": # 策略类型: 跑策略选股 → 对比上期选股池 → 产出 new_entry/dropped 事件 hit_rows = self._match_strategy(scoped, rule) elif rtype == "ladder": # 连板梯队封单监控: 独立处理 (需带预警封单值, 走专属 message) return self._evaluate_ladder(scoped, rule, now) else: # signal / price / market: 通用条件匹配 for sym, name, price, pct, hit_sigs in self._match_conditions(scoped, rule): hit_rows.append((rtype, sym, name, price, pct, hit_sigs)) if not hit_rows: return [] # 3. cooldown 去重 + 生成 events cooldown = rule.get("cooldown_seconds", 3600) severity = rule.get("severity", "info") source = rtype events: list[dict] = [] for ev_type, sym, name, price, pct, hit_sigs in hit_rows: # cooldown 键: 批量事件用特殊键, 单只事件用 (rule_id, symbol) is_batch = sym == "_batch" if is_batch: key = (rule["id"], f"_{ev_type}_batch") else: key = (rule["id"], sym) last = self._last_fire.get(key) if last is not None and (now - last) < cooldown: continue # 冷却期内, 跳过 self._last_fire[key] = now # 批量事件: name 存放预构建的消息文本 if is_batch: resolved_name = "" message = name # name 字段即批量消息 else: resolved_name = name if name else self._name_map.get(sym) message = rule.get("message", "") or self._default_message( rule, ev_type=ev_type, sym=sym, name=resolved_name, pct=pct, price=price, conditions=list(rule.get("conditions", [])) if rule.get("type") != "strategy" else None, ) ev = { "ts": int(now * 1000), "rule_id": rule["id"], "rule_name": rule.get("name", ""), "source": source, "type": ev_type, "symbol": "" if is_batch else sym, "name": resolved_name, "message": message, "price": price, "change_pct": pct, "signals": hit_sigs, "severity": severity, # 触发条件快照 (signal/price/market 类型): 用于触发记录展示 # 「命中了什么条件」。strategy 类型靠策略选股池 diff, 不写条件。 "conditions": list(rule.get("conditions", [])) if rtype != "strategy" else [], "logic": rule.get("logic", "and") if rtype != "strategy" else "and", } events.append(ev) if self._alert_handler: try: self._alert_handler(ev) except Exception as e: logger.warning("alert handler failed: %s", e) return events @staticmethod def _apply_scope(df: pl.DataFrame, rule: dict) -> pl.DataFrame: """按 scope 过滤 DataFrame。""" scope = rule.get("scope", "symbols") if scope == "all": return df if scope == "symbols": syms = rule.get("symbols", []) if not syms: return df.head(0) return df.filter(pl.col("symbol").is_in(syms)) if scope == "sector": # sector 过滤: 需 df 含板块列 (后续接入 ext_data JOIN) # 当前先返回全量, sector 精确过滤第二步完善 return df return df def _match_strategy( self, df: pl.DataFrame, rule: dict, ) -> list[tuple[str, str, Any, Any, Any, list[str]]]: """策略类型评估: 跑策略选股 → 对比上期选股池 → 产出变更事件。 返回 [(event_type, symbol, name, price, pct, signals)] event_type: "new_entry" (新入选) | "dropped" (已移出) 单只变更逐只返回; 同一策略 >5 只合并为一条批量事件 (symbol="_batch") """ if self._strategy_engine is None: return [] sid = rule.get("strategy_id") if not sid: return [] try: s = self._strategy_engine.get(sid) except Exception: return [] if s is None: return [] # 运行策略选股: 复用当前 enriched DataFrame 跳过数据加载 overrides = {} if self._data_dir: try: overrides = _strategy_config.load_override(self._data_dir, sid) except Exception: pass # 声明 filter_history 的策略 (如反包) 需要多日历史窗口才能判定形态。 # 旧实现因"实时监控不支持 history loader"直接跳过 → 反包等策略盘中永不触发。 # 现接入 history_loader, 拼历史窗口 + 今日实时行情, 经 precomputed_history 喂给引擎。 # loader 为 None (未装配) 时退回跳过, 保持旧行为, 不破坏无历史场景。 run_kwargs: dict = { "as_of": _dt.date.today(), "overrides": overrides, } if s.filter_history_fn: if self._history_loader is None: logger.debug("策略 %s 需要历史数据但未注入 history_loader, 跳过实时监控", sid) return [] try: today = _dt.date.today() lookback = max(1, getattr(s, "lookback_days", 30)) hist_df = self._history_loader(today, lookback) if hist_df is None or hist_df.is_empty(): logger.debug("策略 %s 历史数据为空, 跳过本轮实时监控", sid) return [] # 历史窗口可能与今日已落盘数据重叠: 排掉 hist_df 中 date==today 的行, # 今日行情始终以实时 df 为准 (盘中逐轮更新, 最接近收盘真相)。 # 否则 today 行重复会污染 filter_history 的 .over("symbol") 窗口判定。 if "date" in hist_df.columns: hist_df = hist_df.filter(pl.col("date") != today) # 拼接历史窗口 + 今日实时行情 (filter_history 用 .over("symbol") 窗口, 多日天然可用) run_kwargs["precomputed_history"] = pl.concat( [hist_df, df], how="diagonal_relaxed" ) except Exception as e: logger.warning("策略 %s 加载历史窗口失败, 跳过: %s", sid, e) return [] else: # 普通策略: 复用当前 enriched DataFrame 跳过数据加载 run_kwargs["precomputed"] = df try: result = self._strategy_engine.run(sid, **run_kwargs) except Exception as e: logger.warning("策略 %s 选股执行失败: %s", sid, e) return [] # 记录本轮完整选股结果 (供策略页实时回显: /cached 端点直接读取, 不落盘)。 # 与下面的 diff 事件无关 — 无论是否产生 new_entry/dropped, 结果都该可用于回显。 try: import math self._latest_strategy_results[sid] = { "total": result.total, "as_of": str(_dt.date.today()), "rows": [ {k: (None if isinstance(v, float) and not math.isfinite(v) else v) for k, v in row.items()} for row in result.rows ], } except Exception: # noqa: BLE001 pass current_pool: set[str] = {r["symbol"] for r in result.rows} prev_pool = self._strategy_pools.get(sid) # 首次运行: 仅记录当前选股池, 不产生事件 if prev_pool is None: self._strategy_pools[sid] = current_pool return [] new_entries = current_pool - prev_pool dropped = prev_pool - current_pool # 无变更 if not new_entries and not dropped: return [] # 更新存储 self._strategy_pools[sid] = current_pool sname = s.meta.get("name", "") or s.meta.get("id", sid) # 构建查找表 (新入选股票可在 result.rows 中找到; 移出股票需从 df 找) row_map: dict[str, dict] = {r["symbol"]: r for r in result.rows} dropped_map: dict[str, dict] = {} if dropped: try: _dd = df.filter(pl.col("symbol").is_in(list(dropped))) for row in _dd.iter_rows(named=True): dropped_map[row["symbol"]] = row except Exception: pass results: list[tuple[str, str, Any, Any, Any, list[str]]] = [] # ── 新入选 ── new_list = sorted(new_entries) if len(new_list) > 5: names: list[str] = [] for sym in new_list: row = row_map.get(sym, {}) name = row.get("name") or self._name_map.get(sym, sym) names.append(str(name)) message = f"策略「{sname}」进入 {len(new_entries)} 只:{'、'.join(names)}" results.append(("new_entry", "_batch", message, None, None, [])) else: for sym in new_list: row = row_map.get(sym, {}) name = row.get("name") or self._name_map.get(sym, sym) price = row.get("close") pct = row.get("change_pct") results.append(("new_entry", sym, name, price, pct, [])) # ── 已移出 ── dropped_list = sorted(dropped) if len(dropped_list) > 5: names = [] for sym in dropped_list: row = dropped_map.get(sym, {}) name = row.get("name") or self._name_map.get(sym, sym) names.append(str(name)) message = f"策略「{sname}」移出 {len(dropped)} 只:{'、'.join(names)}" results.append(("dropped", "_batch", message, None, None, [])) else: for sym in dropped_list: row = dropped_map.get(sym, {}) name = row.get("name") or self._name_map.get(sym, sym) price = row.get("close") pct = row.get("change_pct") results.append(("dropped", sym, name, price, pct, [])) return results @staticmethod def _match_conditions( df: pl.DataFrame, rule: dict, ) -> list[tuple[str, Any, Any, Any, list[str]]]: """按 conditions + logic 匹配,返回命中行 [(symbol,name,price,pct,signals)]。""" conditions = rule.get("conditions", []) logic = rule.get("logic", "and") if not conditions: return [] hit_df = _build_condition_mask(df, conditions, logic) results = [] for row in hit_df.iter_rows(named=True): sym = row.get("symbol", "") name = row.get("name") price = row.get("close") pct = row.get("change_pct") # 收集命中的信号列名 (仅 op=truth 且为真的) hit_sigs = [ c["field"] for c in conditions if c.get("op") == "truth" and row.get(c["field"]) ] results.append((sym, name, price, pct, hit_sigs)) return results def _evaluate_ladder(self, scoped: pl.DataFrame, rule: dict, now: float) -> list[dict]: """评估连板梯队封单监控规则。 封单量从注入的临时列 _sealed_vol (手) 读取 (由 quote_service 评估前注入)。 命中条件: 封单比较值 <= threshold (且封单 > 0, 排除无 depth 数据的股票)。 涨停(direction=up) → 炸板预警; 跌停(direction=down) → 翘板预警。 """ if "_sealed_vol" not in scoped.columns: return [] # 无封单数据 (depth 未拉取), 安全降级 metric = rule.get("metric", "sealed_vol") threshold = rule.get("threshold", 0) direction = rule.get("direction", "up") cooldown = rule.get("cooldown_seconds", 600) severity = rule.get("severity", "warn") # 比较值: sealed_vol 直接用 (手), sealed_amount = 手 × 100股 × close if metric == "sealed_amount": cmp_expr = pl.col("_sealed_vol") * 100 * pl.col("close") unit = "元" else: cmp_expr = pl.col("_sealed_vol") unit = "手" # 命中: 封单 > 0 (有数据) 且 比较值 <= 阈值 hit = scoped.filter( pl.col("_sealed_vol").is_not_null() & (pl.col("_sealed_vol") > 0) & (cmp_expr <= threshold) ) if hit.is_empty(): return [] warn_label = "炸板预警" if direction == "up" else "翘板预警" events: list[dict] = [] for row in hit.iter_rows(named=True): sym = row.get("symbol", "") key = (rule["id"], sym) last = self._last_fire.get(key) if last is not None and (now - last) < cooldown: continue self._last_fire[key] = now name = row.get("name") or self._name_map.get(sym) or sym price = row.get("close") pct = row.get("change_pct") sealed_vol = row.get("_sealed_vol") # 预警封单值 (展示用) sealed_value = sealed_vol * 100 * (price or 0) if metric == "sealed_amount" else sealed_vol # message 体现预警封单量 + 阈值 if metric == "sealed_amount": sv_text = f"{sealed_value / 1e4:.0f}万{unit}" th_text = f"{threshold / 1e4:.0f}万{unit}" else: sv_text = f"{sealed_value:,.0f} {unit}" th_text = f"{threshold:,.0f} {unit}" message = f"{warn_label} · 封单 {sv_text} ≤ {th_text}" events.append({ "ts": int(now * 1000), "rule_id": rule["id"], "rule_name": rule.get("name", ""), "source": "ladder", "type": warn_label, "symbol": sym, "name": name, "message": message, "price": price, "change_pct": pct, "signals": [], "severity": severity, "conditions": [], "logic": "and", "sealed_value": sealed_value, # 预警封单量/额 (飞书+记录展示) "sealed_metric": metric, }) return events def _default_message(self, rule: dict, ev_type: str = "", sym: str = "", name: str = "", pct: Any = None, price: Any = None, conditions: list[dict] | None = None) -> str: """生成默认 message。 - strategy: 按变更方向生成 (进入/移出 + 涨跌幅) - signal/price/market: 条件摘要 + 现价 + 涨跌幅 (避免笼统的「信号触发」) """ rtype = rule.get("type", "signal") if rtype == "strategy": # 从 StrategyEngine 取策略名; 失败则退化为 rule_name 里截取的部分 sname = "" sid = rule.get("strategy_id") if sid and self._strategy_engine is not None: try: s = self._strategy_engine.get(sid) sname = s.meta.get("name", "") or s.meta.get("id", "") except Exception: # noqa: BLE001 sname = "" if not sname: rn = rule.get("name", "") sname = rn.split(" · ", 1)[1] if " · " in rn else (rn or "策略") if ev_type == "new_entry": pct_text = "" if pct is not None: sign = "+" if pct >= 0 else "" pct_text = f" {sign}{pct * 100:.1f}%" return f"策略「{sname}」进入 {name}{pct_text}" elif ev_type == "dropped": pct_text = "" if pct is not None: sign = "+" if pct >= 0 else "" pct_text = f" {sign}{pct * 100:.1f}%" return f"策略「{sname}」移出 {name}{pct_text}" return f"策略「{sname}」变更" # signal / price / market: 条件摘要 + 现价 + 涨跌幅 # 条件摘要: 把 conditions (truth/比较) 拼成可读串, 如 "MA20金叉 且 量比>2" cond_text = self._format_conditions_text(rule, conditions) price_text = f"现价 {price}" if price is not None else "" pct_text = "" if pct is not None: sign = "+" if pct >= 0 else "" pct_text = f"{sign}{pct * 100:.1f}%" tail = " · ".join(s for s in (price_text, pct_text) if s) if cond_text and tail: return f"{cond_text} · {tail}" return cond_text or tail or "监控触发" @staticmethod def _format_conditions_text(rule: dict, conditions: list[dict] | None) -> str: """把 rule.conditions 拼成可读文本 (用于 message / 推送)。 op=truth: 直接用信号中文名 (如 "MA20金叉") op=比较: 字段中文名 + 操作符 + 值 (如 "涨跌幅≥5") logic: and → "且", or → "或" """ conds = conditions if conditions is not None else list(rule.get("conditions", [])) if not conds: return "" logic_word = "且" if rule.get("logic", "and") == "and" else "或" parts: list[str] = [] for c in conds: field = c.get("field", "") op = c.get("op", "truth") value = c.get("value") label = _signal_cn_name(field) or field if op == "truth": parts.append(label) else: op_map = {"gte": "≥", "lte": "≤", "gt": ">", "lt": "<", "eq": "="} parts.append(f"{label}{op_map.get(op, op)}{value}") return f" {logic_word} ".join(parts)