"""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