321 lines
11 KiB
Python
321 lines
11 KiB
Python
"""
|
||
飞书通知服务 - 通过 Webhook 发送交易信号通知
|
||
支持加密货币和股票两个独立的 webhook
|
||
"""
|
||
import json
|
||
import httpx
|
||
from typing import Dict, Any, Optional
|
||
from app.utils.logger import logger
|
||
from app.config import get_settings
|
||
|
||
|
||
class FeishuService:
|
||
"""飞书机器人通知服务"""
|
||
|
||
def __init__(self, webhook_url: str = "", service_type: str = "crypto"):
|
||
"""
|
||
初始化飞书服务
|
||
|
||
Args:
|
||
webhook_url: 飞书机器人 Webhook URL(如果为空,则根据 service_type 从配置读取)
|
||
service_type: 服务类型 ("crypto" 或 "stock")
|
||
"""
|
||
settings = get_settings()
|
||
|
||
# 如果传入了 webhook_url,直接使用
|
||
if webhook_url:
|
||
self.webhook_url = webhook_url
|
||
else:
|
||
# 否则根据服务类型从配置读取
|
||
if service_type == "crypto":
|
||
self.webhook_url = getattr(settings, 'feishu_crypto_webhook_url', '')
|
||
elif service_type == "stock":
|
||
self.webhook_url = getattr(settings, 'feishu_stock_webhook_url', '')
|
||
elif service_type == "news":
|
||
self.webhook_url = getattr(settings, 'feishu_news_webhook_url', '')
|
||
else:
|
||
# 兼容旧配置
|
||
self.webhook_url = getattr(settings, 'feishu_webhook_url', '')
|
||
|
||
# 检查配置开关和 webhook_url 是否都有效
|
||
config_enabled = getattr(settings, 'feishu_enabled', True)
|
||
self.enabled = config_enabled and bool(self.webhook_url)
|
||
self.service_type = service_type
|
||
|
||
if not config_enabled:
|
||
logger.info(f"飞书通知已通过配置禁用")
|
||
elif self.enabled:
|
||
logger.info(f"飞书通知服务初始化完成 ({service_type})")
|
||
else:
|
||
logger.warning(f"飞书 Webhook URL 未配置 ({service_type}),通知功能已禁用")
|
||
|
||
async def send_text(self, message: str) -> bool:
|
||
"""
|
||
发送文本消息
|
||
|
||
Args:
|
||
message: 消息内容
|
||
|
||
Returns:
|
||
是否发送成功
|
||
"""
|
||
if not self.enabled:
|
||
logger.warning("飞书服务未启用,跳过发送")
|
||
return False
|
||
|
||
data = {
|
||
"msg_type": "text",
|
||
"content": {
|
||
"text": message
|
||
}
|
||
}
|
||
|
||
return await self._send(data)
|
||
|
||
async def send_card(self, title: str, content: str, color: str = "blue") -> bool:
|
||
"""
|
||
发送卡片消息
|
||
|
||
Args:
|
||
title: 卡片标题
|
||
content: 卡片内容(支持 Markdown)
|
||
color: 标题颜色 (blue, green, red, orange, purple)
|
||
|
||
Returns:
|
||
是否发送成功
|
||
"""
|
||
if not self.enabled:
|
||
logger.warning("飞书服务未启用,跳过发送")
|
||
return False
|
||
|
||
# 颜色映射
|
||
color_map = {
|
||
"blue": "blue",
|
||
"green": "green",
|
||
"red": "red",
|
||
"orange": "orange",
|
||
"purple": "purple"
|
||
}
|
||
|
||
data = {
|
||
"msg_type": "interactive",
|
||
"card": {
|
||
"header": {
|
||
"title": {
|
||
"tag": "plain_text",
|
||
"content": title
|
||
},
|
||
"template": color_map.get(color, "blue")
|
||
},
|
||
"elements": [
|
||
{
|
||
"tag": "markdown",
|
||
"content": content
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
return await self._send(data)
|
||
|
||
async def send_trading_signal(self, signal: Dict[str, Any]) -> bool:
|
||
"""
|
||
发送交易信号卡片
|
||
|
||
Args:
|
||
signal: 交易信号数据
|
||
- symbol: 交易对
|
||
- action: 'buy' | 'sell'
|
||
- price: 当前价格
|
||
- trend: 趋势方向
|
||
- confidence: 信号强度 (0-100)
|
||
- signal_type: 'swing' | 'short_term'
|
||
- signal_grade: 'A' | 'B' | 'C' | 'D'
|
||
- indicators: 技术指标数据
|
||
- llm_analysis: LLM 分析结果(可选)
|
||
- stop_loss: 建议止损价
|
||
- take_profit: 建议止盈价
|
||
|
||
Returns:
|
||
是否发送成功
|
||
"""
|
||
if not self.enabled:
|
||
logger.warning("飞书服务未启用,跳过发送")
|
||
return False
|
||
|
||
action = signal.get('action', 'hold')
|
||
symbol = signal.get('symbol', 'UNKNOWN')
|
||
price = signal.get('price', 0)
|
||
trend = signal.get('trend', 'neutral')
|
||
confidence = signal.get('confidence', 0)
|
||
signal_type = signal.get('signal_type', 'swing')
|
||
signal_grade = signal.get('signal_grade', 'D')
|
||
indicators = signal.get('indicators', {})
|
||
llm_analysis = signal.get('llm_analysis', '')
|
||
stop_loss = signal.get('stop_loss', 0)
|
||
take_profit = signal.get('take_profit', 0)
|
||
reasons = signal.get('reasons', [])
|
||
|
||
# 信号类型文本
|
||
type_text = "📈 短线信号" if signal_type == 'short_term' else "📊 波段信号"
|
||
type_hint = "(快进快出,建议轻仓)" if signal_type == 'short_term' else "(趋势跟踪,可适当持仓)"
|
||
|
||
# 确定标题和颜色
|
||
if action == 'buy':
|
||
title = f"🟢 买入信号 - {symbol} [{type_text}]"
|
||
color = "green"
|
||
action_text = "做多"
|
||
elif action == 'sell':
|
||
title = f"🔴 卖出信号 - {symbol} [{type_text}]"
|
||
color = "red"
|
||
action_text = "做空"
|
||
else:
|
||
title = f"⚪ 观望 - {symbol}"
|
||
color = "blue"
|
||
action_text = "观望"
|
||
|
||
# 趋势文本
|
||
trend_text = {
|
||
'bullish': '看涨 📈',
|
||
'bearish': '看跌 📉',
|
||
'neutral': '震荡 ↔️'
|
||
}.get(trend, '未知')
|
||
|
||
# 等级图标
|
||
grade_icon = {'A': '⭐⭐⭐', 'B': '⭐⭐', 'C': '⭐', 'D': ''}.get(signal_grade, '')
|
||
type_short = "短线" if signal_type == 'short_term' else "波段"
|
||
|
||
# 构建精简内容 - 突出核心交易信息
|
||
content_parts = [
|
||
f"**{type_short}** | {signal_grade}{grade_icon} | {confidence}% | {trend_text}",
|
||
f"**入场**: ${price:,.2f}",
|
||
]
|
||
|
||
# 止损止盈(核心点位)
|
||
if stop_loss > 0:
|
||
sl_percent = ((stop_loss - price) / price) * 100
|
||
content_parts.append(f"**止损**: ${stop_loss:,.2f} ({sl_percent:+.1f}%)")
|
||
if take_profit > 0:
|
||
tp_percent = ((take_profit - price) / price) * 100
|
||
content_parts.append(f"**止盈**: ${take_profit:,.2f} ({tp_percent:+.1f}%)")
|
||
|
||
# 触发原因(精简)
|
||
if reasons:
|
||
clean_reasons = []
|
||
for reason in reasons[:2]:
|
||
clean = reason.replace("📊 ", "").replace("📈 ", "").replace("📉 ", "").replace("波段信号: ", "").replace("短线", "").replace("超跌反弹", "").replace("超涨回落", "")
|
||
if clean and len(clean) < 20:
|
||
clean_reasons.append(clean)
|
||
if clean_reasons:
|
||
content_parts.append(f"**原因**: {' | '.join(clean_reasons)}")
|
||
|
||
# AI 分析
|
||
if llm_analysis:
|
||
analysis_text = llm_analysis[:100] + "..." if len(llm_analysis) > 100 else llm_analysis
|
||
content_parts.append(f"**AI**: {analysis_text}")
|
||
|
||
# 免责声明
|
||
content_parts.append("*⚠️ 仅供参考*")
|
||
|
||
content = "\n".join(content_parts)
|
||
|
||
return await self.send_card(title, content, color)
|
||
|
||
async def send_trend_change(self, symbol: str, old_trend: str, new_trend: str, price: float) -> bool:
|
||
"""
|
||
发送趋势变化通知
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
old_trend: 旧趋势
|
||
new_trend: 新趋势
|
||
price: 当前价格
|
||
|
||
Returns:
|
||
是否发送成功
|
||
"""
|
||
trend_emoji = {
|
||
'bullish': '📈',
|
||
'bearish': '📉',
|
||
'neutral': '↔️'
|
||
}
|
||
|
||
trend_text = {
|
||
'bullish': '看涨',
|
||
'bearish': '看跌',
|
||
'neutral': '震荡'
|
||
}
|
||
|
||
title = f"🔄 {symbol} 趋势变化"
|
||
content = f"{trend_text.get(old_trend, old_trend)}{trend_emoji.get(old_trend, '')} → {trend_text.get(new_trend, new_trend)}{trend_emoji.get(new_trend, '')} | ${price:,.2f}"
|
||
|
||
return await self.send_card(title, content, "orange")
|
||
|
||
async def _send(self, data: Dict[str, Any]) -> bool:
|
||
"""
|
||
发送消息到飞书
|
||
|
||
Args:
|
||
data: 消息数据
|
||
|
||
Returns:
|
||
是否发送成功
|
||
"""
|
||
try:
|
||
async with httpx.AsyncClient() as client:
|
||
response = await client.post(
|
||
self.webhook_url,
|
||
json=data,
|
||
headers={"Content-Type": "application/json"},
|
||
timeout=10.0
|
||
)
|
||
|
||
result = response.json()
|
||
|
||
if result.get('code') == 0 or result.get('StatusCode') == 0:
|
||
logger.info("飞书消息发送成功")
|
||
return True
|
||
else:
|
||
logger.error(f"飞书消息发送失败: {result}")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"飞书消息发送异常: {e}")
|
||
return False
|
||
|
||
|
||
|
||
# 全局实例(延迟初始化)- 分别用于加密货币、股票和新闻
|
||
_feishu_crypto_service: Optional[FeishuService] = None
|
||
_feishu_stock_service: Optional[FeishuService] = None
|
||
_feishu_news_service: Optional[FeishuService] = None
|
||
|
||
|
||
def get_feishu_service() -> FeishuService:
|
||
"""获取飞书服务实例(默认 crypto,保持向后兼容)"""
|
||
return get_feishu_crypto_service()
|
||
|
||
|
||
def get_feishu_crypto_service() -> FeishuService:
|
||
"""获取加密货币飞书服务实例"""
|
||
global _feishu_crypto_service
|
||
if _feishu_crypto_service is None:
|
||
_feishu_crypto_service = FeishuService(service_type="crypto")
|
||
return _feishu_crypto_service
|
||
|
||
|
||
def get_feishu_stock_service() -> FeishuService:
|
||
"""获取股票飞书服务实例"""
|
||
global _feishu_stock_service
|
||
if _feishu_stock_service is None:
|
||
_feishu_stock_service = FeishuService(service_type="stock")
|
||
return _feishu_stock_service
|
||
|
||
|
||
def get_feishu_news_service() -> FeishuService:
|
||
"""获取新闻智能体飞书服务实例"""
|
||
global _feishu_news_service
|
||
if _feishu_news_service is None:
|
||
_feishu_news_service = FeishuService(service_type="news")
|
||
return _feishu_news_service
|