feat(monitor): 触发记录展示命中条件与当前价位

告警事件落盘时增加 conditions + logic 条件快照, 经 quote_service 透传到
SSE 推送;前端触发记录详情行渲染命中条件与现价, 替代原先仅显示
「信号触发」的通用文案。
This commit is contained in:
shy3130
2026-06-29 17:17:08 +08:00
parent ec30e35991
commit cd76796a5c
3 changed files with 86 additions and 4 deletions
+54
View File
@@ -635,6 +635,8 @@ class QuoteService:
return
all_alerts: list[dict] = []
rule_events: list[dict] = []
engine = None
# 通用监控规则评估 (统一引擎: signal/price/market/strategy)
if self._app_state:
@@ -675,6 +677,8 @@ class QuoteService:
"change_pct": ev["change_pct"],
"signals": ev["signals"],
"severity": ev.get("severity", "info"),
"conditions": ev.get("conditions") or [],
"logic": ev.get("logic") or "and",
})
# Free 自选实时只刷新少量标的, 不写全市场策略缓存。
@@ -696,9 +700,59 @@ class QuoteService:
# cooldown 去重已在 MonitorRuleEngine 做过, 这里只负责转发。
self._maybe_send_system_notifications(all_alerts)
# Webhook 推送 (飞书等外部 IM, 由规则 webhook_enabled 开关控制)。
# 紧随系统通知, 同样静默降级不阻断主流程。
if rule_events:
self._maybe_send_webhook(rule_events, engine)
except Exception as e: # noqa: BLE001
logger.warning("监控评估失败: %s", e)
def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None:
"""把告警通过 Webhook 推送到外部 IM (由规则 webhook_enabled 开关控制)。
- 全局飞书 URL 未配置: 直接返回
- 仅推送 webhook_enabled=True 的规则触发的告警
- 失败静默, 不阻断主流程
- 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重
注意: 用 rule_events (含 rule_id) 而非重建后的 all_alerts,
以便反查引擎规则判断是否启用推送。
"""
try:
from app.services import preferences
from app.services import webhook_adapter
url = preferences.get_feishu_webhook_url()
if not url:
return
secret = preferences.get_feishu_webhook_secret()
# 反查规则, 过滤出启用推送的事件
source_labels = {
"strategy": "策略", "signal": "信号",
"price": "价格", "market": "异动",
}
rules = engine.rules if engine is not None else {}
pushed = 0
for ev in rule_events:
rule = rules.get(ev.get("rule_id"))
if not rule or not rule.get("webhook_enabled"):
continue
source = ev.get("source", "")
source_label = source_labels.get(source, source or "通知")
symbol = ev.get("symbol") or ""
name = ev.get("name") or ""
message = ev.get("message") or ""
title = f"TickFlow · {source_label}"
body = f"{symbol} {name} {message}".strip() if symbol else (message or name)
if webhook_adapter.send_feishu(url, title, body, secret):
pushed += 1
if pushed:
logger.info("飞书 Webhook 推送: %d", pushed)
except Exception as e: # noqa: BLE001
logger.debug("Webhook 推送异常 (不影响告警主流程): %s", e)
def _maybe_send_system_notifications(self, all_alerts: list[dict]) -> None:
"""把告警转发到操作系统通知中心 (由 preferences 开关控制)。
+4
View File
@@ -410,6 +410,10 @@ class MonitorRuleEngine:
"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:
+28 -4
View File
@@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion'
import { RadioTower, Plus, Trash2, Settings2, Zap, Bell, ListChecks, BellRing, TrendingUp, TrendingDown, Flame } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { api, type MonitorRule, type AlertEvent } from '@/lib/api'
import { api, type MonitorRule, type AlertEvent, type MonitorCondition } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtPrice, fmtPct } from '@/lib/format'
import { cn } from '@/lib/cn'
@@ -367,9 +367,33 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
})()}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px]">{renderMessage(ev.source, ev.message)}</span>
</div>
{/* 详情行: 命中条件 (signal/price/market) + 当前价 / 或默认消息 */}
{(ev.conditions && ev.conditions.length > 0) ? (
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px]">
<span className="text-muted"></span>
{ev.conditions.map((c: MonitorCondition, ci: number) => (
<span key={ci} className="inline-flex items-center gap-0.5">
{ci > 0 && <span className="text-secondary">{ev.logic === 'or' ? '或' : '且'}</span>}
{c.op === 'truth' ? (
<span className="text-accent/80">{cnSignal(c.field)}</span>
) : (
<span className="text-foreground/80 font-mono">{cnSignal(c.field)}{c.op}{c.value}</span>
)}
</span>
))}
{ev.price != null && (
<>
<span className="text-muted">·</span>
<span className="text-muted"></span>
<span className="font-mono text-foreground/90">{fmtPrice(ev.price)}</span>
</>
)}
</div>
) : (
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px]">{renderMessage(ev.source, ev.message)}</span>
</div>
)}
{ev.signals && ev.signals.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{ev.signals.map((s: string, j: number) => (