301 lines
9.5 KiB
Python
301 lines
9.5 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
|