365 lines
16 KiB
Python
365 lines
16 KiB
Python
import logging
|
||
from datetime import datetime, timezone, timedelta
|
||
from typing import List, Optional
|
||
from data_fetcher import BinanceDataFetcher
|
||
from technical_analyzer import TechnicalAnalyzer, CoinSignal
|
||
from database import DatabaseManager
|
||
from dingtalk_notifier import DingTalkNotifier
|
||
import os
|
||
|
||
# 东八区时区
|
||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||
|
||
def get_beijing_time():
|
||
"""获取当前东八区时间用于显示"""
|
||
return datetime.now(BEIJING_TZ)
|
||
|
||
class CoinSelectionEngine:
|
||
def __init__(self, api_key=None, secret=None, db_path="trading.db", use_market_cap_ranking=True, dingtalk_webhook=None, dingtalk_secret=None):
|
||
"""初始化选币引擎
|
||
|
||
Args:
|
||
api_key: Binance API密钥
|
||
secret: Binance API密钥
|
||
db_path: 数据库路径
|
||
use_market_cap_ranking: 是否使用市值排名(True=市值排名,False=交易量排名)
|
||
dingtalk_webhook: 钉钉机器人webhook URL
|
||
dingtalk_secret: 钉钉机器人加签密钥
|
||
"""
|
||
self.data_fetcher = BinanceDataFetcher(api_key, secret)
|
||
self.analyzer = TechnicalAnalyzer()
|
||
self.db = DatabaseManager(db_path)
|
||
self.use_market_cap_ranking = use_market_cap_ranking
|
||
|
||
# 初始化钉钉通知器
|
||
# 优先使用传入的参数,其次使用环境变量
|
||
webhook_url = dingtalk_webhook or os.getenv('DINGTALK_WEBHOOK_URL')
|
||
webhook_secret = dingtalk_secret or os.getenv('DINGTALK_WEBHOOK_SECRET')
|
||
self.dingtalk_notifier = DingTalkNotifier(webhook_url, webhook_secret)
|
||
|
||
# 配置日志
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||
handlers=[
|
||
logging.StreamHandler(), # 确保输出到控制台
|
||
logging.FileHandler('coin_selection.log')
|
||
],
|
||
force=True # 强制重新配置日志
|
||
)
|
||
self.logger = logging.getLogger(__name__)
|
||
|
||
def run_coin_selection(self) -> List[CoinSignal]:
|
||
"""执行完整的选币流程 - 使用动态时间周期"""
|
||
self.logger.info("开始执行选币流程...")
|
||
|
||
try:
|
||
# 1. 获取Top50 USDT交易对(按市值或交易量排序)
|
||
if self.use_market_cap_ranking:
|
||
self.logger.info("获取市值排名Top50 USDT交易对...")
|
||
top_symbols = self.data_fetcher.get_top_market_cap_usdt_pairs(50)
|
||
else:
|
||
self.logger.info("获取交易量Top50 USDT交易对...")
|
||
top_symbols = self.data_fetcher.get_top_usdt_pairs(50)
|
||
|
||
if not top_symbols:
|
||
self.logger.error("无法获取交易对数据")
|
||
return []
|
||
|
||
# 2. 获取动态时间周期配置
|
||
optimal_timeframes = self.analyzer.get_optimal_timeframes_for_analysis()
|
||
self.logger.info(f"使用动态时间周期: {optimal_timeframes}")
|
||
|
||
# 3. 批量获取市场数据
|
||
self.logger.info(f"获取{len(top_symbols)}个币种的市场数据...")
|
||
market_data = self.data_fetcher.batch_fetch_data(top_symbols, optimal_timeframes)
|
||
|
||
if not market_data:
|
||
self.logger.error("无法获取市场数据")
|
||
return []
|
||
|
||
# 4. 执行技术分析选币 - 同时输出多空信号
|
||
self.logger.info("执行技术分析,寻找多空投资机会...")
|
||
|
||
# 先测试策略分布(调试用)
|
||
self.logger.info("测试策略分布...")
|
||
self.analyzer.test_strategy_distribution(market_data)
|
||
|
||
signals_result = self.analyzer.select_coins(market_data)
|
||
|
||
# 获取所有信号
|
||
all_signals = signals_result.get('all', [])
|
||
long_signals = signals_result.get('long', [])
|
||
short_signals = signals_result.get('short', [])
|
||
|
||
if not all_signals:
|
||
self.logger.warning("没有找到符合条件的多空信号")
|
||
return []
|
||
|
||
# 5. 打印策略分布统计
|
||
self._log_strategy_distribution(all_signals)
|
||
|
||
# 6. 保存选币结果到数据库
|
||
self.logger.info(f"保存{len(all_signals)}个选币结果到数据库...")
|
||
saved_count = 0
|
||
saved_long = 0
|
||
saved_short = 0
|
||
|
||
for signal in all_signals:
|
||
try:
|
||
selection_id = self.db.insert_coin_selection(
|
||
symbol=signal.symbol,
|
||
qualified_factors=signal.qualified_factors,
|
||
reason=signal.reason,
|
||
entry_price=signal.entry_price,
|
||
stop_loss=signal.stop_loss,
|
||
take_profit=signal.take_profit,
|
||
timeframe=signal.timeframe,
|
||
strategy_type=signal.strategy_type,
|
||
holding_period=signal.holding_period,
|
||
risk_reward_ratio=signal.risk_reward_ratio,
|
||
expiry_hours=signal.expiry_hours,
|
||
action_suggestion=signal.action_suggestion,
|
||
signal_type=signal.signal_type,
|
||
direction=signal.direction
|
||
)
|
||
signal_type_cn = "多头" if signal.signal_type == "LONG" else "空头"
|
||
self.logger.info(f"保存{signal.symbol}({signal.strategy_type}-{signal_type_cn})选币结果,ID: {selection_id}")
|
||
saved_count += 1
|
||
|
||
# 统计实际保存的多空数量
|
||
if signal.signal_type == "LONG":
|
||
saved_long += 1
|
||
else:
|
||
saved_short += 1
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"保存{signal.symbol}选币结果失败: {e}")
|
||
|
||
# 检查并标记过期的选币
|
||
self.db.check_and_expire_selections()
|
||
|
||
self.logger.info(f"选币完成!成功保存{saved_count}个信号(多头: {saved_long}个, 空头: {saved_short}个)")
|
||
|
||
# 发送钉钉通知
|
||
try:
|
||
self.logger.info("发送钉钉通知...")
|
||
notification_sent = self.dingtalk_notifier.send_coin_selection_notification(all_signals)
|
||
if notification_sent:
|
||
self.logger.info("✅ 钉钉通知发送成功")
|
||
else:
|
||
self.logger.info("📱 钉钉通知发送失败或未配置")
|
||
except Exception as e:
|
||
self.logger.error(f"发送钉钉通知时出错: {e}")
|
||
|
||
return all_signals
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"选币流程执行失败: {e}")
|
||
return []
|
||
|
||
def _log_strategy_distribution(self, signals: List[CoinSignal]):
|
||
"""统计并记录策略分布"""
|
||
strategy_stats = {}
|
||
for signal in signals:
|
||
strategy = signal.strategy_type
|
||
signal_type = signal.signal_type
|
||
key = f"{strategy}-{signal_type}"
|
||
|
||
if key not in strategy_stats:
|
||
strategy_stats[key] = {'count': 0, 'avg_factors': 0, 'factors': []}
|
||
|
||
strategy_stats[key]['count'] += 1
|
||
strategy_stats[key]['factors'].append(signal.qualified_factors)
|
||
|
||
# 计算平均符合因子数
|
||
for key, stats in strategy_stats.items():
|
||
stats['avg_factors'] = sum(stats['factors']) / len(stats['factors'])
|
||
|
||
self.logger.info("策略分布统计:")
|
||
for key, stats in sorted(strategy_stats.items(), key=lambda x: x[1]['count'], reverse=True):
|
||
self.logger.info(f" {key}: {stats['count']}个信号, 平均符合因子: {stats['avg_factors']:.1f}/4")
|
||
|
||
def run_strategy_specific_analysis(self, symbols: List[str], strategy_name: str) -> List[CoinSignal]:
|
||
"""针对特定策略运行专门的分析"""
|
||
try:
|
||
# 获取该策略需要的时间周期
|
||
required_timeframes = self.analyzer.get_required_timeframes().get(strategy_name, ['1h', '4h', '1d'])
|
||
|
||
self.logger.info(f"对{len(symbols)}个币种执行{strategy_name}策略分析,使用时间周期: {required_timeframes}")
|
||
|
||
# 获取对应的市场数据
|
||
market_data = self.data_fetcher.batch_fetch_data(symbols, required_timeframes)
|
||
|
||
if not market_data:
|
||
self.logger.error("无法获取市场数据")
|
||
return []
|
||
|
||
# 强制使用指定策略进行分析
|
||
signals = []
|
||
for symbol, data in market_data.items():
|
||
timeframe_data = data.get('timeframes', {})
|
||
volume_24h_usd = data.get('volume_24h_usd', 0)
|
||
|
||
# 直接使用指定策略分析
|
||
symbol_signals = self.analyzer.analyze_single_coin_with_strategy(
|
||
symbol, timeframe_data, volume_24h_usd, strategy_name
|
||
)
|
||
signals.extend(symbol_signals)
|
||
|
||
self.logger.info(f"{strategy_name}策略分析完成,找到{len(signals)}个信号")
|
||
return signals
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"{strategy_name}策略分析失败: {e}")
|
||
return []
|
||
|
||
def get_latest_selections(self, limit=20, offset=0) -> List[dict]:
|
||
"""获取最新的选币结果 - 支持分页"""
|
||
try:
|
||
results = self.db.get_active_selections(limit, offset)
|
||
selections = []
|
||
|
||
for row in results:
|
||
# 使用字段名而不是索引来避免错误
|
||
conn = self.db.get_connection()
|
||
cursor = conn.cursor()
|
||
cursor.execute('''
|
||
SELECT id, symbol, qualified_factors, reason, entry_price, stop_loss, take_profit,
|
||
timeframe, selection_time, status, actual_entry_price, exit_price,
|
||
exit_time, pnl_percentage, notes, strategy_type, holding_period,
|
||
risk_reward_ratio, expiry_time, is_expired, action_suggestion,
|
||
signal_type, direction
|
||
FROM coin_selections
|
||
WHERE id = ?
|
||
''', (row[0],))
|
||
|
||
detailed_row = cursor.fetchone()
|
||
conn.close()
|
||
|
||
if detailed_row:
|
||
selection = {
|
||
'id': detailed_row[0],
|
||
'symbol': detailed_row[1],
|
||
'qualified_factors': detailed_row[2],
|
||
'reason': detailed_row[3],
|
||
'entry_price': detailed_row[4],
|
||
'stop_loss': detailed_row[5],
|
||
'take_profit': detailed_row[6],
|
||
'timeframe': detailed_row[7],
|
||
'selection_time': detailed_row[8], # 保持UTC时间,在Web层转换
|
||
'status': detailed_row[9],
|
||
'actual_entry_price': detailed_row[10],
|
||
'exit_price': detailed_row[11],
|
||
'exit_time': detailed_row[12],
|
||
'pnl_percentage': detailed_row[13],
|
||
'notes': detailed_row[14],
|
||
'strategy_type': detailed_row[15] or '中线',
|
||
'holding_period': detailed_row[16] or 7,
|
||
'risk_reward_ratio': detailed_row[17] or 2.0,
|
||
'expiry_time': detailed_row[18],
|
||
'is_expired': detailed_row[19] or False,
|
||
'action_suggestion': detailed_row[20] or '等待回调买入',
|
||
'signal_type': detailed_row[21] or 'LONG',
|
||
'direction': detailed_row[22] or 'BUY'
|
||
}
|
||
|
||
# 获取当前价格
|
||
current_price = self.data_fetcher.get_current_price(detailed_row[1])
|
||
if current_price:
|
||
selection['current_price'] = current_price
|
||
selection['price_change_percent'] = ((current_price - detailed_row[4]) / detailed_row[4]) * 100
|
||
|
||
selections.append(selection)
|
||
|
||
return selections
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"获取选币结果失败: {e}")
|
||
return []
|
||
|
||
def update_selection_status(self, selection_id: int, status: str, exit_price: Optional[float] = None):
|
||
"""更新选币状态"""
|
||
try:
|
||
pnl_percentage = None
|
||
|
||
if exit_price and status in ['completed', 'stopped']:
|
||
# 计算收益率
|
||
conn = self.db.get_connection()
|
||
cursor = conn.cursor()
|
||
cursor.execute('SELECT entry_price FROM coin_selections WHERE id = ?', (selection_id,))
|
||
result = cursor.fetchone()
|
||
|
||
if result:
|
||
entry_price = result[0]
|
||
pnl_percentage = ((exit_price - entry_price) / entry_price) * 100
|
||
|
||
conn.close()
|
||
|
||
self.db.update_selection_status(selection_id, status, exit_price, pnl_percentage)
|
||
self.logger.info(f"更新选币{selection_id}状态为{status}")
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"更新选币状态失败: {e}")
|
||
|
||
def set_dingtalk_webhook(self, webhook_url: str, webhook_secret: str = None):
|
||
"""设置钉钉webhook URL和密钥
|
||
|
||
Args:
|
||
webhook_url: 钉钉机器人webhook URL
|
||
webhook_secret: 钉钉机器人加签密钥(可选)
|
||
"""
|
||
self.dingtalk_notifier = DingTalkNotifier(webhook_url, webhook_secret)
|
||
self.logger.info("钉钉webhook配置已更新")
|
||
|
||
def test_dingtalk_notification(self) -> bool:
|
||
"""测试钉钉通知功能
|
||
|
||
Returns:
|
||
bool: 测试是否成功
|
||
"""
|
||
return self.dingtalk_notifier.send_test_message()
|
||
|
||
def print_selection_summary(self, signals: List[CoinSignal]):
|
||
"""打印选币结果摘要"""
|
||
if not signals:
|
||
self.logger.warning("没有找到符合条件的币种")
|
||
print("没有找到符合条件的币种")
|
||
return
|
||
|
||
summary = f"\n=== 选币结果 ({get_beijing_time().strftime('%Y-%m-%d %H:%M:%S CST')}) ===\n"
|
||
summary += f"共选出 {len(signals)} 个币种:\n\n"
|
||
|
||
for i, signal in enumerate(signals, 1):
|
||
summary += f"{i}. {signal.symbol} [{signal.strategy_type}] - {signal.action_suggestion}\n"
|
||
summary += f" 符合因子: {signal.qualified_factors}/4 ({signal.confidence}信心)\n"
|
||
summary += f" 理由: {signal.reason}\n"
|
||
summary += f" 入场: ${signal.entry_price:.4f}\n"
|
||
summary += f" 止损: ${signal.stop_loss:.4f} ({((signal.stop_loss - signal.entry_price) / signal.entry_price * 100):.2f}%)\n"
|
||
summary += f" 止盈: ${signal.take_profit:.4f} ({((signal.take_profit - signal.entry_price) / signal.entry_price * 100):.2f}%)\n"
|
||
summary += f" 风险回报比: 1:{signal.risk_reward_ratio:.2f}\n"
|
||
summary += f" 持仓周期: {signal.holding_period}天 | 有效期: {signal.expiry_hours}小时\n"
|
||
summary += "-" * 50 + "\n"
|
||
|
||
self.logger.info("选币结果摘要:")
|
||
print(summary)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# 创建选币引擎实例
|
||
from config import *
|
||
|
||
engine = CoinSelectionEngine(
|
||
api_key=BINANCE_API_KEY,
|
||
secret=BINANCE_SECRET,
|
||
db_path=DATABASE_PATH,
|
||
use_market_cap_ranking=USE_MARKET_CAP_RANKING,
|
||
dingtalk_webhook=DINGTALK_WEBHOOK_URL,
|
||
dingtalk_secret=DINGTALK_WEBHOOK_SECRET
|
||
)
|
||
|
||
# 执行选币
|
||
selected_coins = engine.run_coin_selection()
|
||
|
||
# 打印结果
|
||
engine.print_selection_summary(selected_coins) |