608 lines
22 KiB
Python
608 lines
22 KiB
Python
"""
|
||
通知模块 - 支持钉钉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("通知管理器初始化完成") |