trading.ai/src/utils/notification.py
2025-09-23 16:12:18 +08:00

608 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
通知模块 - 支持钉钉webhook通知
"""
import requests
import json
import time
import hmac
import hashlib
import base64
import urllib.parse
from typing import Dict, Any, Optional
from loguru import logger
from datetime import datetime
class DingTalkNotifier:
"""钉钉机器人通知器"""
def __init__(self, webhook_url: str, secret: str = None):
"""
初始化钉钉通知器
Args:
webhook_url: 钉钉机器人webhook地址
secret: 加签密钥,如果提供则启用加签验证
"""
self.webhook_url = webhook_url
self.secret = secret
self.session = requests.Session()
logger.info(f"钉钉通知器初始化完成 {'(已启用加签)' if secret else '(未启用加签)'}")
def _generate_signature(self, timestamp: str) -> str:
"""
生成钉钉加签
Args:
timestamp: 时间戳字符串
Returns:
加签结果
"""
if not self.secret:
return ""
string_to_sign = f"{timestamp}\n{self.secret}"
hmac_code = hmac.new(
self.secret.encode('utf-8'),
string_to_sign.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
return sign
def _get_signed_url(self) -> str:
"""
获取带加签的webhook URL
Returns:
带加签的URL如果未配置密钥则返回原URL
"""
if not self.secret:
return self.webhook_url
timestamp = str(round(time.time() * 1000))
sign = self._generate_signature(timestamp)
# 添加时间戳和签名参数
separator = '&' if '?' in self.webhook_url else '?'
return f"{self.webhook_url}{separator}timestamp={timestamp}&sign={sign}"
def send_text_message(self, content: str, at_all: bool = False, at_mobiles: list = None) -> bool:
"""
发送文本消息
Args:
content: 消息内容
at_all: 是否@所有人
at_mobiles: @指定手机号列表
Returns:
发送是否成功
"""
try:
data = {
"msgtype": "text",
"text": {
"content": content
}
}
# 添加@功能
if at_all or at_mobiles:
data["at"] = {}
if at_all:
data["at"]["isAtAll"] = True
if at_mobiles:
data["at"]["atMobiles"] = at_mobiles
response = self.session.post(
self._get_signed_url(),
json=data,
headers={'Content-Type': 'application/json'},
timeout=10
)
if response.status_code == 200:
result = response.json()
if result.get('errcode') == 0:
logger.info("钉钉消息发送成功")
return True
else:
logger.error(f"钉钉消息发送失败: {result.get('errmsg', '未知错误')}")
return False
else:
logger.error(f"钉钉API请求失败: HTTP {response.status_code}")
return False
except Exception as e:
logger.error(f"发送钉钉消息异常: {e}")
return False
def send_markdown_message(self, title: str, text: str, at_all: bool = False, at_mobiles: list = None) -> bool:
"""
发送Markdown格式消息
Args:
title: 消息标题
text: Markdown格式的消息内容
at_all: 是否@所有人
at_mobiles: @指定手机号列表
Returns:
发送是否成功
"""
try:
data = {
"msgtype": "markdown",
"markdown": {
"title": title,
"text": text
}
}
# 添加@功能
if at_all or at_mobiles:
data["at"] = {}
if at_all:
data["at"]["isAtAll"] = True
if at_mobiles:
data["at"]["atMobiles"] = at_mobiles
response = self.session.post(
self._get_signed_url(),
json=data,
headers={'Content-Type': 'application/json'},
timeout=10
)
if response.status_code == 200:
result = response.json()
if result.get('errcode') == 0:
logger.info("钉钉Markdown消息发送成功")
return True
else:
logger.error(f"钉钉Markdown消息发送失败: {result.get('errmsg', '未知错误')}")
return False
else:
logger.error(f"钉钉API请求失败: HTTP {response.status_code}")
return False
except Exception as e:
logger.error(f"发送钉钉Markdown消息异常: {e}")
return False
def send_strategy_summary_message(self, title: str, markdown_text: str) -> bool:
"""
发送策略汇总消息Markdown格式
Args:
title: 消息标题
markdown_text: Markdown格式的消息内容
Returns:
发送是否成功
"""
return self.send_markdown_message(title, markdown_text)
def send_strategy_signal(self, stock_code: str, stock_name: str, timeframe: str,
signal_type: str, price: float, signal_date: str = None, additional_info: Dict[str, Any] = None) -> bool:
"""
发送策略信号通知(优化版:创新高回踩确认)
Args:
stock_code: 股票代码
stock_name: 股票名称
timeframe: 时间周期
signal_type: 信号类型
price: 当前价格
signal_date: 信号发生的时间K线时间
additional_info: 额外信息
Returns:
发送是否成功
"""
try:
# 使用信号时间或当前时间
display_time = signal_date if signal_date else datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 构建Markdown消息
title = f"🎯 {signal_type}信号确认"
# 基础信息
markdown_text = f"""
# 🎯 {signal_type}信号确认
**股票信息:**
- 代码: `{stock_code}`
- 名称: `{stock_name}`
- 确认价格: `{price}` 元
- 时间周期: `{timeframe}`
**确认时间:** {display_time}
**策略说明:** 两阳+阴+阳突破(创新高回踩确认)
"""
# 添加创新高回踩确认的详细信息
if additional_info:
# 检查是否有创新高回踩确认的关键信息
if 'new_high_price' in additional_info and 'new_high_date' in additional_info:
markdown_text += f"""
**🚀 创新高回踩确认详情:**
- 📅 模式日期: `{additional_info.get('pattern_date', '未知')}`
- 💰 原突破价: `{additional_info.get('breakout_price', 'N/A')}` 元
- 🌟 创新高价: `{additional_info.get('new_high_price', 'N/A')}` 元
- 🚀 创新高日期: `{additional_info.get('new_high_date', '未知')}`
- 🎯 阴线最高价: `{additional_info.get('yin_high', 'N/A')}` 元
- ✅ 回踩确认日期: `{additional_info.get('confirmation_date', '未知')}`
- ⏰ 总确认用时: `{additional_info.get('confirmation_days', 'N/A')}` 天
- 📏 回踩距离: `{additional_info.get('pullback_distance', 'N/A')}%`
"""
# 添加其他额外信息
markdown_text += "\n**📊 技术指标:**\n"
tech_indicators = ['yang1_entity_ratio', 'yang2_entity_ratio', 'final_yang_entity_ratio',
'breakout_pct', 'turnover_ratio', 'above_ema20']
for key in tech_indicators:
if key in additional_info:
value = additional_info[key]
if key.endswith('_ratio') and isinstance(value, (int, float)):
markdown_text += f"- {key}: `{value:.1%}`\n"
elif key == 'breakout_pct':
markdown_text += f"- 突破幅度: `{value:.2f}%`\n"
elif key == 'turnover_ratio':
markdown_text += f"- 换手率: `{value:.2f}%`\n"
elif key == 'above_ema20':
status = '✅上方' if value else '❌下方'
markdown_text += f"- EMA20位置: `{status}`\n"
else:
markdown_text += f"- {key}: `{value}`\n"
markdown_text += """
---
**💡 操作建议:**
- ✅ 信号已通过创新高回踩双重确认
- 📈 突破有效性得到验证
- 🎯 当前为较优入场时机
- ⚠️ 注意风险控制,设置合理止损
**🔍 关键确认要素:**
1. 🎯 形态: 两阳+阴+阳突破完成
2. 🚀 创新高: 价格突破形态高点
3. 📉 回踩: 回踩至阴线最高价附近
4. ✅ 时机: 7天内完成双重确认
---
*K线形态策略 - 创新高回踩确认版*
"""
return self.send_markdown_message(title, markdown_text)
except Exception as e:
logger.error(f"发送策略信号通知异常: {e}")
return False
class NotificationManager:
"""通知管理器"""
def __init__(self, config: Dict[str, Any]):
"""
初始化通知管理器
Args:
config: 通知配置
"""
self.config = config
self.dingtalk_notifier = None
# 初始化钉钉通知器
dingtalk_config = config.get('dingtalk', {})
if dingtalk_config.get('enabled', False):
webhook_url = dingtalk_config.get('webhook_url')
secret = dingtalk_config.get('secret')
if webhook_url:
self.dingtalk_notifier = DingTalkNotifier(webhook_url, secret)
logger.info("钉钉通知器已启用")
else:
logger.warning("钉钉通知已启用但未配置webhook_url")
def send_strategy_signal(self, stock_code: str, stock_name: str, timeframe: str,
signal_type: str, price: float, signal_date: str = None, additional_info: Dict[str, Any] = None) -> bool:
"""
发送策略信号到所有启用的通知渠道
Args:
stock_code: 股票代码
stock_name: 股票名称
timeframe: 时间周期
signal_type: 信号类型
price: 当前价格
signal_date: 信号发生的时间K线时间
additional_info: 额外信息
Returns:
是否至少有一个渠道发送成功
"""
success = False
# 钉钉通知
if self.dingtalk_notifier:
if self.dingtalk_notifier.send_strategy_signal(
stock_code, stock_name, timeframe, signal_type, price, signal_date, additional_info
):
success = True
# 记录到日志
logger.info(f"策略信号: {signal_type} | {stock_code}({stock_name}) | {timeframe} | {price}")
if additional_info:
logger.info(f"额外信息: {additional_info}")
return success
def send_strategy_summary(self, all_signals: Dict[str, Any], scan_stats: Dict[str, Any] = None) -> bool:
"""
发送策略信号汇总通知(支持分组发送)
Args:
all_signals: 所有信号的汇总数据 {stock_code: {timeframe: [signals]}}
scan_stats: 扫描统计信息
Returns:
发送是否成功
"""
if not all_signals:
return False
try:
from datetime import datetime
import math
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 收集所有信号详情
all_signal_details = []
total_signals = 0
total_stocks = len(all_signals)
for stock_code, stock_results in all_signals.items():
for timeframe, signals in stock_results.items():
for signal in signals:
total_signals += 1
# 根据新的信号格式提取信息
confirmation_date = signal.get('confirmation_date', signal['date'])
new_high_price = signal.get('new_high_price', signal['breakout_price'])
confirmation_days = signal.get('confirmation_days', 0)
all_signal_details.append({
'stock_code': stock_code,
'stock_name': signal.get('stock_name', '未知'),
'timeframe': timeframe,
'pattern_date': signal['date'], # 模式形成日期
'confirmation_date': confirmation_date, # 回踩确认日期
'price': new_high_price, # 创新高价格
'original_breakout_price': signal['breakout_price'], # 原突破价
'yin_high': signal.get('yin_high', 0), # 阴线最高价
'turnover': signal.get('turnover_ratio', 0),
'breakout_pct': signal.get('breakout_pct', 0),
'ema20_status': '✅上方' if signal.get('above_ema20', False) else '❌下方',
'confirmation_days': confirmation_days,
'pullback_distance': signal.get('pullback_distance', 0),
'is_new_format': signal.get('new_high_confirmed', False) # 是否为新格式信号
})
# 如果没有信号,直接返回
if total_signals == 0:
return True
# 按10个信号为一组分批发送
signals_per_group = 10
total_groups = math.ceil(total_signals / signals_per_group)
success_count = 0
for group_idx in range(total_groups):
start_idx = group_idx * signals_per_group
end_idx = min(start_idx + signals_per_group, total_signals)
group_signals = all_signal_details[start_idx:end_idx]
# 构建当前组的消息
if total_groups > 1:
title = f"🎯 K线形态策略信号汇总 ({group_idx + 1}/{total_groups})"
else:
title = f"🎯 K线形态策略信号汇总"
markdown_text = f"""
# {title}
**扫描统计:**
- 扫描时间: `{current_time}`
- 总信号数: `{total_signals}` 个
- 本组信号: `{len(group_signals)}` 个 ({start_idx + 1}-{end_idx})
- 涉及股票: `{total_stocks}` 只
"""
# 添加扫描范围信息
if scan_stats:
markdown_text += f"""
**扫描范围:**
- 扫描股票总数: `{scan_stats.get('total_scanned', 'N/A')}`
- 数据源: `{scan_stats.get('data_source', '热门股票')}`
"""
markdown_text += "\n**✅ 确认信号详情:**\n"
# 添加当前组的信号详情
for i, signal in enumerate(group_signals, start_idx + 1):
if signal['is_new_format']:
# 新格式:创新高回踩确认
markdown_text += f"""
{i}. **{signal['stock_code']} - {signal['stock_name']}** 🎯
- 📅 模式日期: `{signal['pattern_date']}`
- ✅ 确认日期: `{signal['confirmation_date']}`
- 💰 原突破价: `{signal['original_breakout_price']:.2f}元`
- 🌟 创新高价: `{signal['price']:.2f}元`
- 🎯 阴线高点: `{signal['yin_high']:.2f}元`
- ⏰ 确认用时: `{signal['confirmation_days']}天`
- 📏 回踩距离: `{signal['pullback_distance']:.2f}%`
- 📊 周期: `{signal['timeframe']}` | 换手: `{signal['turnover']:.2f}%`
- 📈 EMA20: `{signal['ema20_status']}`
"""
else:
# 旧格式:兼容显示
markdown_text += f"""
{i}. **{signal['stock_code']} - {signal['stock_name']}**
- K线时间: `{signal['pattern_date']}`
- 时间周期: `{signal['timeframe']}`
- 当前价格: `{signal['price']:.2f}元`
- 突破幅度: `{signal['breakout_pct']:.2f}%`
- 换手率: `{signal['turnover']:.2f}%`
- EMA20: `{signal['ema20_status']}`
"""
markdown_text += """
---
**🔍 策略说明:** 两阳+阴+阳突破(创新高回踩确认版)
**💡 信号特点:**
- ✅ 所有信号已通过双重确认
- 🎯 模式出现后等待创新高验证
- 📉 创新高后回踩阴线最高价入场
- ⏰ 7天内完成完整确认流程
**⚠️ 风险提示:** 投资有风险,入市需谨慎!
---
*K线形态策略系统自动发送*
"""
# 发送当前组的通知
if self.dingtalk_notifier:
if self.dingtalk_notifier.send_markdown_message(title, markdown_text):
success_count += 1
logger.info(f"📱 发送信号汇总第{group_idx + 1}组成功 ({len(group_signals)}个信号)")
else:
logger.error(f"📱 发送信号汇总第{group_idx + 1}组失败")
# 避免发送过快,添加短暂延迟
if group_idx < total_groups - 1: # 不是最后一组
import time
time.sleep(1) # 1秒延迟
return success_count > 0
except Exception as e:
logger.error(f"发送策略汇总通知异常: {e}")
return False
def send_pullback_alerts(self, pullback_alerts: list) -> bool:
"""
发送价格回踩阴线最高点的特殊提醒
Args:
pullback_alerts: 回踩提醒信号列表
Returns:
发送是否成功
"""
if not pullback_alerts or not self.dingtalk_notifier:
return False
try:
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 按5个提醒为一组分批发送
alerts_per_group = 5
import math
total_groups = math.ceil(len(pullback_alerts) / alerts_per_group)
success_count = 0
for group_idx in range(total_groups):
start_idx = group_idx * alerts_per_group
end_idx = min(start_idx + alerts_per_group, len(pullback_alerts))
group_alerts = pullback_alerts[start_idx:end_idx]
# 构建标题
title = f"⚠️ 价格回踩阴线最高点提醒 ({group_idx + 1}/{total_groups})"
# 构建详细信息
alert_details = []
for i, alert in enumerate(group_alerts, 1):
alert_detail = f"""
**{start_idx + i}. {alert['stock_code']}({alert['stock_name']})**
- 📅 原信号: {alert['signal_date']} | 当前: {alert['current_date']}
- ⏰ 间隔: {alert['days_since_signal']}天 | 周期: {alert['timeframe']}
- 💰 阴线高点: {alert['yin_high']:.2f}元 | 当时突破价: {alert['breakout_price']:.2f}
- 📉 当前价格: {alert['current_price']:.2f}元 | 今日最低: {alert['current_low']:.2f}
- 📊 回调幅度: {alert['pullback_pct']:.2f}% | 距阴线高点: {alert['distance_to_yin_high']:.2f}%
"""
alert_details.append(alert_detail)
# 构建完整的Markdown消息
markdown_text = f"""
# ⚠️ 已确认信号二次回踩提醒
**🚨 重要提醒:** 以下股票已通过"创新高回踩确认"产生信号,现价格再次回踩至阴线最高点附近,请关注支撑情况!
**📊 本批提醒数量:** {len(group_alerts)}
**🕐 检查时间:** {current_time}
---
{''.join(alert_details)}
---
**💡 操作建议:**
- ✅ 这些股票已通过双重确认,信号有效性较高
- 🎯 当前为二次回踩阴线最高点,关注支撑强度
- 📈 如获得有效支撑,可能形成新的上涨起点
- 📉 如跌破阴线最高点,需要重新评估信号有效性
- 💰 建议结合成交量和其他技术指标综合判断
**🔍 提醒说明:**
- 此类股票已完成创新高+回踩确认流程
- 当前价格位置具有重要技术意义
- 阴线最高点是关键支撑/阻力位
**⚠️ 风险提示:** 本提醒仅供参考,投资有风险,入市需谨慎!
"""
# 发送消息
if self.dingtalk_notifier.send_markdown_message(title, markdown_text):
success_count += 1
logger.info(f"📱 回踩提醒第{group_idx + 1}组发送成功 ({len(group_alerts)}个提醒)")
else:
logger.error(f"📱 回踩提醒第{group_idx + 1}组发送失败")
# 避免发送过快,添加短暂延迟
if group_idx < total_groups - 1:
import time
time.sleep(1) # 1秒延迟
return success_count > 0
except Exception as e:
logger.error(f"发送回踩提醒通知异常: {e}")
return False
def send_test_message(self) -> bool:
"""发送测试消息"""
if self.dingtalk_notifier:
return self.dingtalk_notifier.send_text_message("量化交易系统通知测试 ✅")
return False
if __name__ == "__main__":
# 测试代码
# 注意: 需要有效的钉钉webhook地址才能测试
test_config = {
'dingtalk': {
'enabled': False, # 设置为True并提供webhook_url进行测试
'webhook_url': 'https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN'
}
}
notifier = NotificationManager(test_config)
print("通知管理器初始化完成")