astock-agent/backend/app/notifications/feishu.py
2026-06-10 08:36:25 +08:00

390 lines
13 KiB
Python

"""Feishu/Lark 告警发送"""
from __future__ import annotations
import hashlib
import logging
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
import httpx
from app.config import settings
from app.data.cache import cache
logger = logging.getLogger(__name__)
def _build_signature(
source: str,
message: str,
level: str,
context: dict | None = None,
) -> str:
context = context or {}
basis = "|".join([
source,
level,
message.strip(),
str(context.get("method", "")),
str(context.get("path", "")),
])
return hashlib.sha1(basis.encode("utf-8")).hexdigest()
def _truncate(text: str, limit: int) -> str:
text = (text or "").strip()
if len(text) <= limit:
return text
return f"{text[:limit]}..."
async def send_feishu_alert(
source: str,
message: str,
detail: str = "",
level: str = "error",
context: dict | None = None,
) -> bool:
"""发送 Feishu 告警,内置去重,失败不抛异常。"""
if not settings.alert_enabled or not settings.feishu_webhook_url:
return False
signature = _build_signature(source, message, level, context)
dedup_key = f"feishu_alert:{signature}"
if cache.get(dedup_key):
return False
cache.set(dedup_key, True, settings.alert_dedup_ttl_seconds)
now = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
context = context or {}
detail = _truncate(detail, settings.alert_max_detail_chars)
lines = [
f"[{settings.alert_app_name}] {level.upper()}",
f"环境: {settings.alert_environment}",
f"时间: {now}",
f"来源: {source}",
f"摘要: {message}",
]
if context.get("method") or context.get("path"):
lines.append(
f"请求: {context.get('method', '')} {context.get('path', '')}".strip()
)
if context.get("query"):
lines.append(f"Query: {context['query']}")
if detail:
lines.append(f"详情: {detail}")
payload = {
"msg_type": "text",
"content": {
"text": "\n".join(lines),
},
}
try:
async with httpx.AsyncClient(timeout=8, follow_redirects=True) as client:
resp = await client.post(settings.feishu_webhook_url, json=payload)
resp.raise_for_status()
return True
except Exception as e:
logger.warning("Feishu 告警发送失败: %s", e)
return False
def _recommendation_signature(
recommendations: list[Any],
scan_session: str,
) -> str:
parts = [scan_session]
for rec in recommendations:
parts.append(
"|".join([
str(getattr(rec, "ts_code", "")),
str(getattr(rec, "action_plan", "")),
str(getattr(rec, "score", "")),
str(getattr(rec, "entry_price", "")),
str(getattr(rec, "target_price", "")),
str(getattr(rec, "stop_loss", "")),
])
)
return hashlib.sha1("\n".join(parts).encode("utf-8")).hexdigest()
def _format_price(value: Any) -> str:
if value is None:
return "-"
try:
return f"{float(value):.2f}"
except Exception:
return str(value)
def _format_percent(value: Any) -> str:
if value is None:
return "-"
try:
return f"{float(value):g}%"
except Exception:
return str(value)
def _format_signal_label(value: str) -> str:
labels = {
"breakout": "突破",
"breakout_confirm": "突破确认",
"pullback": "回踩",
"launch": "启动",
"reversal": "反转",
"flow_momentum": "资金动量",
"none": "观察",
"BUY": "买入",
"HOLD": "持有",
}
return labels.get(value, value or "-")
def _card_text(content: str, tag: str = "lark_md") -> dict:
return {"tag": tag, "content": content}
def _recommendation_card_block(index: int, rec: Any) -> list[dict]:
action_plan = getattr(rec, "action_plan", "") or "观察"
name = getattr(rec, "name", "") or "-"
code = getattr(rec, "ts_code", "") or "-"
sector = getattr(rec, "sector", "") or "未归类"
score = float(getattr(rec, "score", 0) or 0)
position = _format_percent(getattr(rec, "suggested_position_pct", 0) or 0)
signal = getattr(rec, "entry_signal_type", "") or getattr(rec, "signal", "") or "-"
entry = _format_price(getattr(rec, "entry_price", None))
target = _format_price(getattr(rec, "target_price", None))
stop = _format_price(getattr(rec, "stop_loss", None))
review_days = getattr(rec, "review_after_days", None)
trigger = _truncate(getattr(rec, "trigger_condition", "") or "等待触发条件确认", 120)
invalidation = _truncate(getattr(rec, "invalidation_condition", "") or "按止损/失效条件处理", 120)
risk_note = _truncate(getattr(rec, "risk_note", "") or "", 100)
signal_label = _format_signal_label(signal)
title = (
f"**{index}. {name}** `{code}`\n"
f"{action_plan} | {sector} | {score:.0f}分 | {signal_label}"
)
price_line = (
f"**仓位** {position} "
f"**入场** {entry} "
f"**目标** {target} "
f"**止损** {stop}"
)
condition_line = f"**触发**: {trigger}\n**失效**: {invalidation}"
if risk_note:
condition_line = f"{condition_line}\n**风险**: {risk_note}"
if review_days:
condition_line = f"{condition_line}\n**复盘**: {review_days} 个交易日"
return [
{"tag": "div", "text": _card_text(title)},
{"tag": "div", "text": _card_text(price_line)},
{"tag": "div", "text": _card_text(condition_line)},
]
def _build_recommendation_card(
selected: list[Any],
market_temp: Any,
scan_session: str,
strategy_profile: dict | None,
now: str,
) -> dict:
actionable_count = sum(1 for rec in selected if getattr(rec, "action_plan", "") == "可操作")
watch_count = sum(1 for rec in selected if getattr(rec, "action_plan", "") == "重点关注")
temp = getattr(market_temp, "temperature", None)
trade_date = getattr(market_temp, "trade_date", "") if market_temp else ""
strategy_name = (strategy_profile or {}).get("name") or (strategy_profile or {}).get("strategy_id") or "-"
stance = (strategy_profile or {}).get("market_stance") or "-"
header_template = "red" if actionable_count else "orange"
summary = "\n".join([
f"**扫描**: {scan_session or 'manual'}",
f"**时间**: {now}",
f"**市场**: {trade_date or '-'} / 温度 {temp if temp is not None else '-'}",
f"**策略**: {strategy_name} / {stance}",
f"**推送**: 可操作 {actionable_count} 只,重点关注 {watch_count}",
])
elements: list[dict] = [
{"tag": "div", "text": _card_text(summary)},
{"tag": "hr"},
]
for index, rec in enumerate(selected, start=1):
if index > 1:
elements.append({"tag": "hr"})
elements.extend(_recommendation_card_block(index, rec))
elements.extend([
{"tag": "hr"},
{
"tag": "note",
"elements": [
_card_text("只推送可操作/重点关注;观察池不推送。请按触发条件确认后再执行。", tag="plain_text")
],
},
])
return {
"config": {"wide_screen_mode": True},
"header": {
"template": header_template,
"title": _card_text(f"{settings.alert_app_name} 股票推荐", tag="plain_text"),
},
"elements": elements,
}
async def send_recommendation_push(
recommendations: list[Any],
market_temp: Any = None,
scan_session: str = "",
strategy_profile: dict | None = None,
) -> bool:
"""推送股票推荐到飞书,失败不影响扫描主流程。"""
webhook_url = settings.recommendation_push_webhook_url or settings.feishu_webhook_url
if not settings.recommendation_push_enabled or not webhook_url:
return False
priority = {"可操作": 0, "重点关注": 1}
selected = [
rec for rec in recommendations
if getattr(rec, "action_plan", "") in priority
]
selected.sort(
key=lambda rec: (
priority.get(getattr(rec, "action_plan", ""), 9),
-float(getattr(rec, "score", 0) or 0),
)
)
selected = selected[: max(settings.recommendation_push_max_items, 1)]
if not selected:
return False
signature = _recommendation_signature(selected, scan_session)
dedup_key = f"feishu_recommendation_push:{signature}"
if cache.get(dedup_key):
return False
cache.set(dedup_key, True, settings.recommendation_push_dedup_ttl_seconds)
now = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
payload = {
"msg_type": "interactive",
"card": _build_recommendation_card(
selected=selected,
market_temp=market_temp,
scan_session=scan_session,
strategy_profile=strategy_profile,
now=now,
),
}
try:
async with httpx.AsyncClient(timeout=8, follow_redirects=True) as client:
resp = await client.post(webhook_url, json=payload)
resp.raise_for_status()
body = resp.json()
if body.get("code", 0) != 0:
logger.warning("Feishu 推荐卡片推送失败: %s", body)
return False
return True
except Exception as e:
logger.warning("Feishu 推荐推送失败: %s", e)
return False
def _research_signature(report: dict) -> str:
basis = "|".join([
str(report.get("scan_session", "")),
str(report.get("trade_date", "")),
str(len(report.get("opportunity_cards", []) or [])),
str((report.get("no_trade_reason") or {}).get("reason", "")),
])
return hashlib.sha1(basis.encode("utf-8")).hexdigest()
def _build_research_card(report: dict, now: str) -> dict:
market = report.get("market_view") or {}
themes = report.get("theme_views") or []
opportunities = report.get("opportunity_cards") or []
risks = report.get("risk_alerts") or []
no_trade = report.get("no_trade_reason") or {}
header_template = "red" if opportunities else "orange"
summary = "\n".join([
f"**扫描**: {report.get('scan_session') or '-'}",
f"**时间**: {now}",
f"**市场状态**: {market.get('summary') or '-'}",
f"**机会卡**: {len(opportunities)} 个 **风险**: {len(risks)}",
])
elements: list[dict] = [{"tag": "div", "text": _card_text(summary)}, {"tag": "hr"}]
if themes:
theme_lines = []
for item in themes[:3]:
nodes = " / ".join((item.get("chain_nodes") or [])[:4])
theme_lines.append(f"**{item.get('theme')}** {item.get('heat_score', 0):.0f}分 · {item.get('lifecycle_status') or item.get('stage')} · {nodes}")
elements.append({"tag": "div", "text": _card_text("**主线/产业链**\n" + "\n".join(theme_lines))})
if opportunities:
elements.append({"tag": "hr"})
lines = []
for item in opportunities[:5]:
lines.append(f"**{item.get('name')}** `{item.get('ts_code')}` · {item.get('opportunity_type')} · {item.get('theme')}/{item.get('chain_node')} · {item.get('score')}")
elements.append({"tag": "div", "text": _card_text("**Top 机会**\n" + "\n".join(lines))})
else:
elements.append({"tag": "hr"})
elements.append({"tag": "div", "text": _card_text(f"**本轮无交易级机会**\n{no_trade.get('reason') or '没有形成满足条件的机会卡。'}")})
if risks:
elements.append({"tag": "hr"})
risk_lines = [f"{'否决' if item.get('reject') else '预警'} · {item.get('reason')}" for item in risks[:3]]
elements.append({"tag": "div", "text": _card_text("**风险雷达**\n" + "\n".join(risk_lines))})
elements.append({
"tag": "note",
"elements": [_card_text("研究日报用于解释市场、主线和风险;交易仍需等待触发条件确认。", tag="plain_text")],
})
return {
"config": {"wide_screen_mode": True},
"header": {
"template": header_template,
"title": _card_text(f"{settings.alert_app_name} 研究日报", tag="plain_text"),
},
"elements": elements,
}
async def send_research_report_push(report: dict) -> bool:
webhook_url = settings.recommendation_push_webhook_url or settings.feishu_webhook_url
if not settings.recommendation_push_enabled or not webhook_url:
return False
signature = _research_signature(report)
dedup_key = f"feishu_research_report:{signature}"
if cache.get(dedup_key):
return False
cache.set(dedup_key, True, settings.recommendation_push_dedup_ttl_seconds)
now = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
payload = {"msg_type": "interactive", "card": _build_research_card(report, now)}
try:
async with httpx.AsyncClient(timeout=8, follow_redirects=True) as client:
resp = await client.post(webhook_url, json=payload)
resp.raise_for_status()
body = resp.json()
if body.get("code", 0) != 0:
logger.warning("Feishu 研究日报推送失败: %s", body)
return False
return True
except Exception as e:
logger.warning("Feishu 研究日报推送失败: %s", e)
return False