"""监控规则 API 路由 — HTTP 请求 → 调用 monitor_rules 模块 → 同步引擎内存态。 只做胶水: 校验 → 持久化 → 失效引擎内存态。不含评估逻辑。 """ from __future__ import annotations from pathlib import Path from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from app.strategy import monitor_rules router = APIRouter(prefix="/api/monitor-rules", tags=["monitor-rules"]) def _data_dir(request: Request) -> Path: return request.app.state.repo.store.data_dir def _sync_engine(request: Request) -> None: """保存/删除后,把最新规则集 reload 到引擎内存态。""" engine = getattr(request.app.state, "monitor_engine", None) if engine is not None: rules = monitor_rules.load_all(_data_dir(request)) engine.set_rules(rules) # ── Pydantic 模型 ─────────────────────────────────────── class ConditionModel(BaseModel): field: str op: str # truth | > >= < <= == != value: float | None = None # op 非 truth 时必填 class RuleModel(BaseModel): id: str name: str enabled: bool = True type: str # strategy | signal | price | market scope: str = "symbols" # symbols | all | sector symbols: list[str] = [] sector: str | None = None strategy_id: str | None = None direction: str = "entry" # entry | exit | both conditions: list[ConditionModel] = [] logic: str = "and" # and | or cooldown_seconds: int = 3600 severity: str = "info" # info | warn | critical webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定) webhook_enabled: bool = False message: str = "" # ladder 专属 (连板梯队封单监控) metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元) threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元) # ── 字段选项 ───────────────────────────────────────────── @router.get("/options") def get_options(request: Request): """返回可选字段、信号列、运算符、枚举,供前端表单使用。""" from app.indicators.pipeline import ENRICHED_COLUMNS from app.strategy.custom_signals import ALLOWED_FIELDS, load_all as load_csg # 阈值字段 (带中文标签) threshold_fields = [ {"key": f, "label": ENRICHED_COLUMNS.get(f, f)} for f in sorted(ALLOWED_FIELDS) ] # 内置信号列 (布尔, 用于 op=truth) builtin_signals = [ {"key": k, "label": v} for k, v in ENRICHED_COLUMNS.items() if k.startswith("signal_") ] # 自定义信号列 (csg_) custom_sigs = [] try: for cs in load_csg(_data_dir(request)): if cs.get("enabled") is not False: custom_sigs.append({ "key": f"csg_{cs['id']}", "label": cs.get("name", cs["id"]), }) except Exception: pass return { "threshold_fields": threshold_fields, "builtin_signals": builtin_signals, "custom_signals": custom_sigs, "operators": [">", ">=", "<", "<=", "==", "!="], "types": [ {"key": "signal", "label": "个股信号"}, {"key": "price", "label": "价格/涨跌"}, {"key": "market", "label": "市场异动"}, {"key": "strategy", "label": "策略监控"}, ], "scopes": [ {"key": "symbols", "label": "指定股票"}, {"key": "all", "label": "全市场"}, {"key": "sector", "label": "板块"}, ], "logics": [ {"key": "and", "label": "全部满足 (AND)"}, {"key": "or", "label": "任一满足 (OR)"}, ], "severities": [ {"key": "info", "label": "普通"}, {"key": "warn", "label": "警告"}, {"key": "critical", "label": "重要"}, ], "directions": [ {"key": "entry", "label": "买入"}, {"key": "exit", "label": "卖出"}, {"key": "both", "label": "买卖都报"}, ], } # ── 列表 ─────────────────────────────────────────────── @router.get("") def list_rules(request: Request): rules = monitor_rules.load_all(_data_dir(request)) # 按 created_at 倒序 rules.sort(key=lambda r: r.get("created_at", ""), reverse=True) return {"rules": rules} # ── 新建 / 更新 ──────────────────────────────────────── @router.post("") def save_rule(req: RuleModel, request: Request): rule = monitor_rules.normalize(req.model_dump()) # 连板梯队封单监控 (type=ladder) 依赖五档盘口数据, 需 Pro+ (DEPTH5_BATCH 能力)。 # 无能力时拒绝创建, 避免规则存了却永远无法触发。 if rule.get("type") == "ladder": from app.tickflow.capabilities import Cap capset = getattr(request.app.state, "capabilities", None) if capset is None or not capset.has(Cap.DEPTH5_BATCH): raise HTTPException( status_code=403, detail="封单监控需要 Pro+ 套餐 (批量五档能力),请升级后在「设置」页配置", ) # 编辑现有规则时, 保留原 created_at (避免按时间排序时位置跳动) existing = monitor_rules.load_one(_data_dir(request), rule["id"]) if existing and existing.get("created_at"): rule["created_at"] = existing["created_at"] try: monitor_rules.validate(rule) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) monitor_rules.save_one(_data_dir(request), rule) _sync_engine(request) return {"ok": True, "rule": rule} # ── 删除 ─────────────────────────────────────────────── @router.delete("/{rule_id}") def delete_rule(rule_id: str, request: Request): if not monitor_rules.ID_RE.match(rule_id): raise HTTPException(status_code=400, detail="规则 id 非法") deleted = monitor_rules.delete_one(_data_dir(request), rule_id) if not deleted: raise HTTPException(status_code=404, detail="规则不存在") _sync_engine(request) return {"ok": True} # ── 演示数据生成 (仅 Dev 页用) ───────────────────────── import time as _time from datetime import datetime, timezone def _demo_rule(rule_id: str, name: str, rtype: str, scope: str, symbols: list[str], conditions: list[dict], logic: str = "or", cooldown: int = 3600, severity: str = "info", message: str = "", strategy_id: str | None = None, direction: str = "entry") -> dict: rule = monitor_rules.normalize({ "id": rule_id, "name": name, "type": rtype, "scope": scope, "symbols": symbols, "conditions": conditions, "logic": logic, "cooldown_seconds": cooldown, "severity": severity, "message": message, "enabled": True, }) if rtype == "strategy": rule["strategy_id"] = strategy_id rule["direction"] = direction return rule _DEMO_RULES_TEMPLATE = [ ("个股信号 · 茅台放量突破", "signal", "symbols", ["600519.SH"], [{"field": "signal_volume_surge", "op": "truth"}, {"field": "signal_n_day_high", "op": "truth"}], "or", "info"), ("个股信号 · 宁德金叉", "signal", "symbols", ["300750.SZ"], [{"field": "signal_ma_golden_5_20", "op": "truth"}], "or", "info"), ("价格 · 平安跌幅监控", "price", "symbols", ["000001.SZ"], [{"field": "change_pct", "op": "<", "value": -0.03}], "or", "warn", "warn"), ("价格 · 比亚迪RSI超卖", "price", "symbols", ["002594.SZ"], [{"field": "rsi_14", "op": "<", "value": 30}], "and", "warn", "warn"), ("市场异动 · 全市场涨停", "market", "all", [], [{"field": "signal_limit_up", "op": "truth"}], "or", "critical", "critical"), ("市场异动 · 全市场炸板", "market", "all", [], [{"field": "signal_broken_limit_up", "op": "truth"}], "or", "warn", "warn"), ("市场异动 · 跌幅超5%", "market", "all", [], [{"field": "change_pct", "op": "<", "value": -0.05}], "or", "warn", "warn"), ("个股信号 · 茅台跌破MA20", "signal", "symbols", ["600519.SH"], [{"field": "signal_ma20_breakdown", "op": "truth"}], "or", "info"), ] # 策略类型单独声明 (格式不同: 含 strategy_id + direction) _DEMO_STRATEGY_RULES: list[dict] = [ {"name": "策略监控 · 趋势突破", "strategy_id": "trend_breakout", "direction": "entry"}, {"name": "策略监控 · MACD金叉", "strategy_id": "macd_golden", "direction": "both"}, ] @router.post("/seed") def seed_demo_rules(request: Request): """生成演示监控规则 (Dev 页用)。覆盖 signal/price/market/strategy 四类。""" ts = int(_time.time() * 1000) created = [] i = 0 for (name, rtype, scope, symbols, conditions, logic, severity, sev) in _DEMO_RULES_TEMPLATE: rule_id = f"demo_{ts}_{i}" rule = _demo_rule(rule_id, name, rtype, scope, symbols, conditions, logic, 3600, sev) monitor_rules.save_one(_data_dir(request), rule) created.append(rule_id) i += 1 # 策略类型规则 for sr in _DEMO_STRATEGY_RULES: rule_id = f"demo_{ts}_{i}" rule = _demo_rule( rule_id, sr["name"], "strategy", "all", [], [], "and", 3600, "info", strategy_id=sr["strategy_id"], direction=sr.get("direction", "entry"), ) monitor_rules.save_one(_data_dir(request), rule) created.append(rule_id) i += 1 _sync_engine(request) return {"ok": True, "generated": len(created), "ids": created} # ── 封单监控模拟触发 (Dev 调试用) ───────────────────── @router.post("/test-ladder") def test_ladder(request: Request): """模拟触发所有 ladder 规则, 返回命中结果 (不落盘、不推送飞书)。 用当前 depth_service 的封单数据 + enriched 最新日 close 构造 mock DataFrame, 跑 _evaluate_ladder 判断哪些规则会触发。供 Dev 页面调试验证。 """ import polars as pl repo = request.app.state.repo depth_svc = getattr(request.app.state, "depth_service", None) engine = getattr(request.app.state, "monitor_engine", None) if not depth_svc: raise HTTPException(status_code=503, detail="depth 服务未初始化") if not engine or not engine.has_rule_type("ladder"): raise HTTPException(status_code=400, detail="无 ladder 类型监控规则") # 最新交易日 latest = repo.enriched_latest_date() if not latest: raise HTTPException(status_code=400, detail="无 enriched 数据") # 取涨停+跌停封单 {symbol: vol} sealed: dict[str, int] = {} for is_down in (False, True): m = depth_svc.get_sealed_map(latest, is_down=is_down) for sym, info in m.items(): vol = (info or {}).get("vol") if vol and vol > 0: sealed[sym] = vol if not sealed: raise HTTPException(status_code=400, detail="无封单数据 (depth 未拉取或无涨停/跌停股)") # 取这些 symbol 的 close (算封单额用) enriched_today, _ = repo.get_enriched_latest() cols = ["symbol", "close", "change_pct"] avail = [c for c in cols if c in enriched_today.columns] mock = enriched_today.select(avail).filter(pl.col("symbol").is_in(list(sealed.keys()))) # 注入 _sealed_vol sealed_df = pl.DataFrame({ "symbol": list(sealed.keys()), "_sealed_vol": list(sealed.values()), }) mock = mock.join(sealed_df, on="symbol", how="inner") # 取所有 ladder 规则, 逐条纯条件判断 (绕过引擎 cooldown, 不污染 _last_fire) ladder_rules = [r for r in engine.rules.values() if r.get("type") == "ladder" and r.get("enabled", True)] all_events = [] not_triggered = [] for rule in ladder_rules: syms = rule.get("symbols", []) sym = syms[0] if syms else None metric = rule.get("metric", "sealed_vol") thr = rule.get("threshold", 0) direction = rule.get("direction", "up") warn_label = "炸板预警" if direction == "up" else "翘板预警" # 取该 symbol 的封单数据 cur_vol = sealed.get(sym) if sym else None row = mock.filter(pl.col("symbol") == sym) if sym else mock.clear() cur_close = row["close"][0] if len(row) and "close" in row.columns else None cur_amt = (cur_vol * 100 * cur_close) if (cur_vol and cur_close) else None cur_val = cur_amt if metric == "sealed_amount" else cur_vol # 条件判断: 封单 > 0 且 比较值 <= 阈值 if cur_val is not None and cur_val > 0 and cur_val <= thr: if metric == "sealed_amount": sv_text = f"{cur_val / 1e4:.0f}万元" th_text = f"{thr / 1e4:.0f}万元" else: sv_text = f"{cur_val:,.0f} 手" th_text = f"{thr:,.0f} 手" all_events.append({ "rule_id": rule["id"], "rule_name": rule.get("name", ""), "symbol": sym, "name": sym, "type": warn_label, "message": f"{warn_label} · 封单 {sv_text} ≤ {th_text}", "severity": rule.get("severity", "warn"), "sealed_value": cur_val, "sealed_metric": metric, "current_sealed_vol": cur_vol, "current_sealed_amount": cur_amt, }) else: reason = "封单数据缺失" if cur_val is None else ( f"封单 {cur_val:,.0f} > 阈值 {thr:,.0f}" if cur_val > thr else "封单为 0" ) not_triggered.append({ "rule_id": rule["id"], "rule_name": rule.get("name", ""), "symbol": sym, "metric": metric, "threshold": thr, "current_value": cur_val, "current_sealed_vol": cur_vol, "current_sealed_amount": cur_amt, "reason": reason, }) return { "ok": True, "as_of": str(latest), "sealed_count": len(sealed), "triggered": all_events, "not_triggered": not_triggered, } @router.post("/trigger-ladder") def trigger_ladder(request: Request): """真实触发一次 ladder 预警 (落盘 + 飞书推送 + SSE), 供 Dev 调试验证完整效果。 与 test-ladder 区别: 本端点会真的把预警写入 alerts.jsonl、推送飞书、触发 SSE, 让用户看到真实的预警通知。绕过 cooldown 强制触发。 """ import time from app.services import alert_store repo = request.app.state.repo depth_svc = getattr(request.app.state, "depth_service", None) engine = getattr(request.app.state, "monitor_engine", None) quote_svc = getattr(request.app.state, "quote_service", None) if not depth_svc: raise HTTPException(status_code=503, detail="depth 服务未初始化") if not engine or not engine.has_rule_type("ladder"): raise HTTPException(status_code=400, detail="无 ladder 类型监控规则") latest = repo.enriched_latest_date() if not latest: raise HTTPException(status_code=400, detail="无 enriched 数据") # 取封单 sealed: dict[str, int] = {} for is_down in (False, True): m = depth_svc.get_sealed_map(latest, is_down=is_down) for sym, info in m.items(): vol = (info or {}).get("vol") if vol and vol > 0: sealed[sym] = vol if not sealed: raise HTTPException(status_code=400, detail="无封单数据") # 构造真实 rule_events (与 _evaluate_ladder 产出格式一致) import polars as pl enriched_today, _ = repo.get_enriched_latest() cols = [c for c in ["symbol", "close", "change_pct"] if c in enriched_today.columns] mock = enriched_today.select(cols).filter(pl.col("symbol").is_in(list(sealed.keys()))) sealed_df = pl.DataFrame({"symbol": list(sealed.keys()), "_sealed_vol": list(sealed.values())}) mock = mock.join(sealed_df, on="symbol", how="inner") now = time.time() rule_events: list[dict] = [] name_map = {} try: inst = repo.get_instruments() if not inst.is_empty() and "name" in inst.columns: name_map = {r["symbol"]: r["name"] for r in inst.select(["symbol", "name"]).iter_rows(named=True) if r.get("name")} except Exception: # noqa: BLE001 pass for rule in engine.rules.values(): if rule.get("type") != "ladder" or not rule.get("enabled", True): continue sym = rule.get("symbols", [""])[0] if rule.get("symbols") else "" metric = rule.get("metric", "sealed_vol") thr = rule.get("threshold", 0) direction = rule.get("direction", "up") warn_label = "炸板预警" if direction == "up" else "翘板预警" row = mock.filter(pl.col("symbol") == sym) if row.is_empty(): continue cur_vol = row["_sealed_vol"][0] close_v = row["close"][0] if "close" in row.columns else None cur_val = cur_vol * 100 * close_v if metric == "sealed_amount" else cur_vol if not cur_val or cur_val <= 0 or cur_val > thr: continue # 不满足条件, 跳过 if metric == "sealed_amount": sv_text = f"{cur_val / 1e4:.0f}万元" th_text = f"{thr / 1e4:.0f}万元" else: sv_text = f"{cur_val:,.0f} 手" th_text = f"{thr:,.0f} 手" rule_events.append({ "ts": int(now * 1000), "rule_id": rule["id"], "rule_name": rule.get("name", ""), "source": "ladder", "type": warn_label, "symbol": sym, "name": name_map.get(sym, sym), "message": f"{warn_label} · 封单 {sv_text} ≤ {th_text}", "price": close_v, "change_pct": row["change_pct"][0] if "change_pct" in row.columns else None, "signals": [], "severity": rule.get("severity", "warn"), "conditions": [], "logic": "and", "sealed_value": cur_val, "sealed_metric": metric, }) if not rule_events: raise HTTPException(status_code=400, detail="当前无 ladder 规则满足触发条件 (封单均 > 阈值)") # 1. 落盘到 alerts.jsonl try: alert_store.append_many(repo.store.data_dir, rule_events) except Exception as e: # noqa: BLE001 pass # 落盘失败不阻断推送 # 2. SSE 推送 (入 pending_alerts 队列) if quote_svc: sse_alerts = [{ "source": ev["source"], "type": ev["type"], "rule_id": ev["rule_id"], "strategy_id": None, "symbol": ev["symbol"], "name": ev["name"], "message": ev["message"], "price": ev["price"], "change_pct": ev["change_pct"], "signals": ev["signals"], "severity": ev["severity"], "conditions": ev["conditions"], "logic": ev["logic"], } for ev in rule_events] try: with quote_svc._lock: quote_svc._pending_alerts.extend(sse_alerts) quote_svc._alert_event.set() except Exception: # noqa: BLE001 pass # 3. 飞书推送 if quote_svc: try: quote_svc._maybe_send_webhook(rule_events, engine) except Exception: # noqa: BLE001 pass return { "ok": True, "triggered": len(rule_events), "events": [{"symbol": ev["symbol"], "name": ev["name"], "message": ev["message"]} for ev in rule_events], }