#!/usr/bin/env python3 """ 每日数据报告定时推送程序 - 每天北京时间 8 点推送昨天的 24 小时预测统计 - 包含各模型准确率、逐小时预测结果详情 """ import schedule import time import logging from datetime import datetime, timedelta from zoneinfo import ZoneInfo from typing import Dict, List from utils.send_msg import lark_notifier from api.models import PredictionModel, convert_to_cst, get_db_connection, TZ_CST, TZ_UTC # 配置日志 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler('logs/daily_report.log'), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) class DailyReportSender: """每日数据报告发送器""" def __init__(self): self.model = PredictionModel() self.lark = lark_notifier def get_yesterday_failed_trades(self) -> List[Dict]: """ 获取昨天的失败交易订单 Returns: [ { 'timestamp': '2026-02-27 10:30:00', 'market_title': 'BTC 价格', 'action': 'BUY', 'status': 'failed', 'error_message': '余额不足' }, ... ] """ try: # 获取昨天的日期(CST) now_cst = datetime.now(TZ_CST) yesterday_cst = now_cst - timedelta(days=1) yesterday_date = yesterday_cst.strftime('%Y-%m-%d') # 计算 CST 时间范围 start_cst = datetime.strptime(f"{yesterday_date} 00:00:00", '%Y-%m-%d %H:%M:%S').replace(tzinfo=TZ_CST) end_cst = start_cst + timedelta(days=1) start_str = start_cst.strftime('%Y-%m-%d %H:%M:%S') end_str = end_cst.strftime('%Y-%m-%d %H:%M:%S') # 从 trades 表查询失败订单 with get_db_connection() as conn: cursor = conn.cursor() sql = """ SELECT timestamp, market_title, action, status, error_message FROM trades WHERE timestamp >= %s AND timestamp < %s AND status != 'MATCHED' ORDER BY timestamp DESC """ cursor.execute(sql, (start_str, end_str)) rows = cursor.fetchall() return rows if rows else [] except Exception as e: logger.error(f"获取昨天失败交易失败: {e}", exc_info=True) return [] def get_yesterday_statistics(self) -> Dict: """ 获取昨天的 24 小时预测统计和详细数据 Returns: { 'date': 'YYYY-MM-DD', 'models': { 'DLinear': {'total': 24, 'correct': 14, 'accuracy': 58.3}, 'N-BEATS': {...}, 'TTM-R2': {...}, 'Ensemble': {...} }, 'hourly_details': [ { 'hour': '00:00-01:00', 'actual': '涨', 'dlinear': {'pred': '涨', 'correct': True}, 'nbeats': {...}, 'ttm': {...}, 'ensemble': {...} }, ... ] } """ try: # 获取昨天的日期(CST) now_cst = datetime.now(TZ_CST) yesterday_cst = now_cst - timedelta(days=1) yesterday_date = yesterday_cst.strftime('%Y-%m-%d') # 计算 UTC 时间范围 start_cst = datetime.strptime(f"{yesterday_date} 00:00:00", '%Y-%m-%d %H:%M:%S').replace(tzinfo=TZ_CST) end_cst = start_cst + timedelta(days=1) start_utc = start_cst.astimezone(TZ_UTC) end_utc = end_cst.astimezone(TZ_UTC) start_str = start_utc.strftime('%Y-%m-%d %H:%M:%S') end_str = end_utc.strftime('%Y-%m-%d %H:%M:%S') # 查询数据库 with get_db_connection() as conn: cursor = conn.cursor() sql = """ SELECT timestamp, actual_result, dlinear_prediction, dlinear_correct, nbeats_prediction, nbeats_correct, ttm_prediction, ttm_correct, ensemble_prediction, ensemble_correct FROM hourly_model_predictions WHERE timestamp >= %s AND timestamp < %s ORDER BY timestamp ASC """ cursor.execute(sql, (start_str, end_str)) rows = cursor.fetchall() # 整理统计数据 models = ['dlinear', 'nbeats', 'ttm', 'ensemble'] model_names = { 'dlinear': 'DLinear', 'nbeats': 'N-BEATS', 'ttm': 'TTM-R2', 'ensemble': 'Ensemble' } # 初始化统计 stats = { 'date': yesterday_date, 'models': {}, 'hourly_details': [] } for model in models: stats['models'][model_names[model]] = { 'total': 0, 'correct': 0, 'accuracy': 0.0 } # 处理每行数据 for idx, row in enumerate(rows): if row.get('actual_result') is None: continue # 获取时间戳(转为 CST 显示) ts_utc = row['timestamp'] if isinstance(ts_utc, str): ts_utc = datetime.fromisoformat(str(ts_utc).replace('Z', '+00:00')) if ts_utc.tzinfo is None: ts_utc = ts_utc.replace(tzinfo=TZ_UTC) ts_cst = ts_utc.astimezone(TZ_CST) hour_start = ts_cst.strftime('%H:%M') hour_end = (ts_cst + timedelta(hours=1)).strftime('%H:%M') hour_label = f"{hour_start}-{hour_end}" # 实际结果 actual_result = row['actual_result'] actual_label = '涨' if actual_result == 1 else '跌' # 每小时预测详情 hourly_item = { 'hour': hour_label, 'actual': actual_label } # 逐模型处理预测结果 for model in models: pred = row[f"{model}_prediction"] correct = row[f"{model}_correct"] # 更新统计 if pred is not None: stats['models'][model_names[model]]['total'] += 1 if correct == 1: stats['models'][model_names[model]]['correct'] += 1 # 添加到小时详情 pred_label = '涨' if pred == 1 else ('跌' if pred == 0 else '无') correct_flag = correct == 1 if correct is not None else None hourly_item[model] = { 'pred': pred_label, 'correct': '✓' if correct_flag else ('✗' if correct_flag is False else '-') } stats['hourly_details'].append(hourly_item) # 计算准确率百分比 for model_name in stats['models']: total = stats['models'][model_name]['total'] correct = stats['models'][model_name]['correct'] if total > 0: stats['models'][model_name]['accuracy'] = round(correct / total * 100, 2) return stats except Exception as e: logger.error(f"获取昨天统计数据失败: {e}", exc_info=True) return None def format_report(self, stats: Dict, failed_trades: List[Dict] = None) -> str: """ 格式化报告为飞书 Markdown 格式 Args: stats: get_yesterday_statistics 返回的数据字典 failed_trades: 失败订单列表 Returns: Markdown 格式的报告文本 """ if not stats: return "❌ 无法获取昨天的统计数据,请检查数据库连接" date = stats['date'] models = stats['models'] # 构建报告头 report = f"**【BTC 日报 - {date}】**\n\n" # 模型准确率统计部分 report += "**📊 模型准确率统计(24小时)**\n" for model_name in ['DLinear', 'N-BEATS', 'TTM-R2', 'Ensemble']: data = models.get(model_name, {}) total = data.get('total', 0) correct = data.get('correct', 0) accuracy = data.get('accuracy', 0) # 进度条可视化 bar_length = 10 filled = int(accuracy / 100 * bar_length) bar = '█' * filled + '░' * (bar_length - filled) report += f"• **{model_name}**: {correct}/{total} {bar} {accuracy}%\n" report += "\n" # 交易失败订单详情 report += "**⚠️ 交易失败订单**\n" if failed_trades and len(failed_trades) > 0: report += f"昨天共有 {len(failed_trades)} 个失败订单\n" for idx, trade in enumerate(failed_trades[:10], 1): # 仅显示前10个 timestamp = trade.get('timestamp', '未知') market_title = trade.get('market_title', '未知市场') action = trade.get('action', '未知') status = trade.get('status', '未知') error_msg = trade.get('error_message', '无详细信息') # 简化错误信息 - 仅取第一行 if error_msg: error_msg = error_msg.split('\n')[0][:50] # 仅取前50字 else: error_msg = '无错误信息' report += f"{idx}. [{timestamp}] {market_title} ({action}) → {error_msg}\n" if len(failed_trades) > 10: report += f"... 还有 {len(failed_trades) - 10} 个失败订单\n" else: report += "昨日无失败订单,运营正常 ✓\n" report += "\n生成时间: " + datetime.now(TZ_CST).strftime('%Y-%m-%d %H:%M:%S CST') return report def send_daily_report(self): """获取昨天统计并推送""" try: logger.info("开始推送每日报告...") stats = self.get_yesterday_statistics() if not stats: logger.warning("无法获取昨天统计数据") self.lark.send_alert( title="⚠️ 每日报告生成失败", content="无法获取昨天的统计数据,请检查数据库连接" ) return # 获取失败订单 failed_trades = self.get_yesterday_failed_trades() # 格式化报告 report = self.format_report(stats, failed_trades) # 推送到飞书 self.lark.send_alert( title="📊 BTC 每日数据报告", content=report ) logger.info(f"每日报告已送出: {stats['date']}") except Exception as e: logger.error(f"推送每日报告异常: {e}", exc_info=True) self.lark.send_alert( title="❌ 每日报告推送异常", content=f"错误详情: {str(e)}" ) def schedule_daily_report(): """ 启动每日报告定时任务 北京时间每天 8 点执行 """ sender = DailyReportSender() sender.send_daily_report() # 启动时立即发送一次(可选) # 使用 schedule 库在北京时间 8 点触发 # 由于 schedule 库基于本地时间,当前环境如果是 macOS 默认时间, # 我们使用一个包装器来处理 CST 时间转换 def cst_8am_job(): """北京时间 8 点执行的任务""" now_cst = datetime.now(TZ_CST) if now_cst.hour == 8 and now_cst.minute < 1: # 确保只在 08:00-08:01 间隔执行一次(避免重复) sender.send_daily_report() # 每分钟检查一次是否需要执行 (确保能在 8 点执行) schedule.every(1).minutes.do(cst_8am_job) logger.info("每日报告定时任务已启动(北京时间每天 8 点执行)") # 启动调度循环 while True: schedule.run_pending() time.sleep(1) def run_scheduler_background(): """ 在后台线程中运行调度器(供 start_system.py 调用) """ import threading scheduler_thread = threading.Thread(target=schedule_daily_report, daemon=True) scheduler_thread.start() logger.info("每日报告调度器已在后台启动") return scheduler_thread if __name__ == '__main__': # 支持单独运行以及从其他模块导入 logger.info("启动每日报告系统...") schedule_daily_report()