Files
daily_stock_analysis/main.py
zhulinsen 80f2da0ff9 fix: 修复 analysis_delay 未定义错误
- 在 run() 方法中添加 analysis_delay 变量定义
- 从配置读取 analysis_delay 值
- 修复静态检查 F821 错误
2026-01-24 14:53:55 +08:00

1074 lines
41 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.

# -*- coding: utf-8 -*-
"""
===================================
A股自选股智能分析系统 - 主调度程序
===================================
职责:
1. 协调各模块完成股票分析流程
2. 实现低并发的线程池调度
3. 全局异常处理,确保单股失败不影响整体
4. 提供命令行入口
使用方式:
python main.py # 正常运行
python main.py --debug # 调试模式
python main.py --dry-run # 仅获取数据不分析
交易理念(已融入分析):
- 严进策略:不追高,乖离率 > 5% 不买入
- 趋势交易:只做 MA5>MA10>MA20 多头排列
- 效率优先:关注筹码集中度好的股票
- 买点偏好:缩量回踩 MA5/MA10 支撑
"""
import os
# 代理配置 - 仅在本地环境使用GitHub Actions 不需要
if os.getenv("GITHUB_ACTIONS") != "true":
# 本地开发环境,如需代理请取消注释或修改端口
# os.environ["http_proxy"] = "http://127.0.0.1:10809"
# os.environ["https_proxy"] = "http://127.0.0.1:10809"
pass
import argparse
import logging
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, date, timezone, timedelta
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import List, Dict, Any, Optional, Tuple
from feishu_doc import FeishuDocManager
from config import get_config, Config
from storage import get_db, DatabaseManager
from data_provider import DataFetcherManager
from data_provider.akshare_fetcher import AkshareFetcher, RealtimeQuote, ChipDistribution
from analyzer import GeminiAnalyzer, AnalysisResult, STOCK_NAME_MAP
from notification import NotificationService, NotificationChannel, send_daily_report
from bot.models import BotMessage
from search_service import SearchService, SearchResponse
from enums import ReportType
from stock_analyzer import StockTrendAnalyzer, TrendAnalysisResult
from market_analyzer import MarketAnalyzer
# 配置日志格式
LOG_FORMAT = '%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s'
LOG_DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
def setup_logging(debug: bool = False, log_dir: str = "./logs") -> None:
"""
配置日志系统(同时输出到控制台和文件)
Args:
debug: 是否启用调试模式
log_dir: 日志文件目录
"""
level = logging.DEBUG if debug else logging.INFO
# 创建日志目录
log_path = Path(log_dir)
log_path.mkdir(parents=True, exist_ok=True)
# 日志文件路径(按日期分文件)
today_str = datetime.now().strftime('%Y%m%d')
log_file = log_path / f"stock_analysis_{today_str}.log"
debug_log_file = log_path / f"stock_analysis_debug_{today_str}.log"
# 创建根 logger
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG) # 根 logger 设为 DEBUG由 handler 控制输出级别
# Handler 1: 控制台输出
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(level)
console_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT))
root_logger.addHandler(console_handler)
# Handler 2: 常规日志文件INFO 级别10MB 轮转)
file_handler = RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5,
encoding='utf-8'
)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT))
root_logger.addHandler(file_handler)
# Handler 3: 调试日志文件DEBUG 级别,包含所有详细信息)
debug_handler = RotatingFileHandler(
debug_log_file,
maxBytes=50 * 1024 * 1024, # 50MB
backupCount=3,
encoding='utf-8'
)
debug_handler.setLevel(logging.DEBUG)
debug_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT))
root_logger.addHandler(debug_handler)
# 降低第三方库的日志级别
logging.getLogger('urllib3').setLevel(logging.WARNING)
logging.getLogger('sqlalchemy').setLevel(logging.WARNING)
logging.getLogger('google').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)
logging.info(f"日志系统初始化完成,日志目录: {log_path.absolute()}")
logging.info(f"常规日志: {log_file}")
logging.info(f"调试日志: {debug_log_file}")
logger = logging.getLogger(__name__)
class StockAnalysisPipeline:
"""
股票分析主流程调度器
职责:
1. 管理整个分析流程
2. 协调数据获取、存储、搜索、分析、通知等模块
3. 实现并发控制和异常处理
"""
def __init__(
self,
config: Optional[Config] = None,
max_workers: Optional[int] = None,
source_message: Optional[BotMessage] = None
):
"""
初始化调度器
Args:
config: 配置对象(可选,默认使用全局配置)
max_workers: 最大并发线程数(可选,默认从配置读取)
"""
self.config = config or get_config()
self.max_workers = max_workers or self.config.max_workers
self.source_message = source_message
# 初始化各模块
self.db = get_db()
self.fetcher_manager = DataFetcherManager()
self.akshare_fetcher = AkshareFetcher() # 用于获取增强数据(量比、筹码等)
self.trend_analyzer = StockTrendAnalyzer() # 趋势分析器
self.analyzer = GeminiAnalyzer()
self.notifier = NotificationService(source_message=source_message)
# 初始化搜索服务
self.search_service = SearchService(
bocha_keys=self.config.bocha_api_keys,
tavily_keys=self.config.tavily_api_keys,
serpapi_keys=self.config.serpapi_keys,
)
logger.info(f"调度器初始化完成,最大并发数: {self.max_workers}")
logger.info("已启用趋势分析器 (MA5>MA10>MA20 多头判断)")
if self.search_service.is_available:
logger.info("搜索服务已启用 (Tavily/SerpAPI)")
else:
logger.warning("搜索服务未启用(未配置 API Key")
def fetch_and_save_stock_data(
self,
code: str,
force_refresh: bool = False
) -> Tuple[bool, Optional[str]]:
"""
获取并保存单只股票数据
断点续传逻辑:
1. 检查数据库是否已有今日数据
2. 如果有且不强制刷新,则跳过网络请求
3. 否则从数据源获取并保存
Args:
code: 股票代码
force_refresh: 是否强制刷新(忽略本地缓存)
Returns:
Tuple[是否成功, 错误信息]
"""
try:
today = date.today()
# 断点续传检查:如果今日数据已存在,跳过
if not force_refresh and self.db.has_today_data(code, today):
logger.info(f"[{code}] 今日数据已存在,跳过获取(断点续传)")
return True, None
# 从数据源获取数据
logger.info(f"[{code}] 开始从数据源获取数据...")
df, source_name = self.fetcher_manager.get_daily_data(code, days=30)
if df is None or df.empty:
return False, "获取数据为空"
# 保存到数据库
saved_count = self.db.save_daily_data(df, code, source_name)
logger.info(f"[{code}] 数据保存成功(来源: {source_name},新增 {saved_count} 条)")
return True, None
except Exception as e:
error_msg = f"获取/保存数据失败: {str(e)}"
logger.error(f"[{code}] {error_msg}")
return False, error_msg
def analyze_stock(self, code: str) -> Optional[AnalysisResult]:
"""
分析单只股票(增强版:含量比、换手率、筹码分析、多维度情报)
流程:
1. 获取实时行情(量比、换手率)
2. 获取筹码分布
3. 进行趋势分析(基于交易理念)
4. 多维度情报搜索(最新消息+风险排查+业绩预期)
5. 从数据库获取分析上下文
6. 调用 AI 进行综合分析
Args:
code: 股票代码
Returns:
AnalysisResult 或 None如果分析失败
"""
try:
# 获取股票名称(优先从实时行情获取真实名称)
stock_name = STOCK_NAME_MAP.get(code, '')
# Step 1: 获取实时行情(量比、换手率等)
realtime_quote: Optional[RealtimeQuote] = None
try:
realtime_quote = self.akshare_fetcher.get_realtime_quote(code)
if realtime_quote:
# 使用实时行情返回的真实股票名称
if realtime_quote.name:
stock_name = realtime_quote.name
logger.info(f"[{code}] {stock_name} 实时行情: 价格={realtime_quote.price}, "
f"量比={realtime_quote.volume_ratio}, 换手率={realtime_quote.turnover_rate}%")
except Exception as e:
logger.warning(f"[{code}] 获取实时行情失败: {e}")
# 如果还是没有名称,使用代码作为名称
if not stock_name:
stock_name = f'股票{code}'
# Step 2: 获取筹码分布
chip_data: Optional[ChipDistribution] = None
try:
chip_data = self.akshare_fetcher.get_chip_distribution(code)
if chip_data:
logger.info(f"[{code}] 筹码分布: 获利比例={chip_data.profit_ratio:.1%}, "
f"90%集中度={chip_data.concentration_90:.2%}")
except Exception as e:
logger.warning(f"[{code}] 获取筹码分布失败: {e}")
# Step 3: 趋势分析(基于交易理念)
trend_result: Optional[TrendAnalysisResult] = None
try:
# 获取历史数据进行趋势分析
context = self.db.get_analysis_context(code)
if context and 'raw_data' in context:
import pandas as pd
raw_data = context['raw_data']
if isinstance(raw_data, list) and len(raw_data) > 0:
df = pd.DataFrame(raw_data)
trend_result = self.trend_analyzer.analyze(df, code)
logger.info(f"[{code}] 趋势分析: {trend_result.trend_status.value}, "
f"买入信号={trend_result.buy_signal.value}, 评分={trend_result.signal_score}")
except Exception as e:
logger.warning(f"[{code}] 趋势分析失败: {e}")
# Step 4: 多维度情报搜索(最新消息+风险排查+业绩预期)
news_context = None
if self.search_service.is_available:
logger.info(f"[{code}] 开始多维度情报搜索...")
# 使用多维度搜索最多3次搜索
intel_results = self.search_service.search_comprehensive_intel(
stock_code=code,
stock_name=stock_name,
max_searches=3
)
# 格式化情报报告
if intel_results:
news_context = self.search_service.format_intel_report(intel_results, stock_name)
total_results = sum(
len(r.results) for r in intel_results.values() if r.success
)
logger.info(f"[{code}] 情报搜索完成: 共 {total_results} 条结果")
logger.debug(f"[{code}] 情报搜索结果:\n{news_context}")
else:
logger.info(f"[{code}] 搜索服务不可用,跳过情报搜索")
# Step 5: 获取分析上下文(技术面数据)
context = self.db.get_analysis_context(code)
if context is None:
logger.warning(f"[{code}] 无法获取分析上下文,跳过分析")
return None
# Step 6: 增强上下文数据(添加实时行情、筹码、趋势分析结果、股票名称)
enhanced_context = self._enhance_context(
context,
realtime_quote,
chip_data,
trend_result,
stock_name # 传入股票名称
)
# Step 7: 调用 AI 分析(传入增强的上下文和新闻)
result = self.analyzer.analyze(enhanced_context, news_context=news_context)
return result
except Exception as e:
logger.error(f"[{code}] 分析失败: {e}")
logger.exception(f"[{code}] 详细错误信息:")
return None
def _enhance_context(
self,
context: Dict[str, Any],
realtime_quote: Optional[RealtimeQuote],
chip_data: Optional[ChipDistribution],
trend_result: Optional[TrendAnalysisResult],
stock_name: str = ""
) -> Dict[str, Any]:
"""
增强分析上下文
将实时行情、筹码分布、趋势分析结果、股票名称添加到上下文中
Args:
context: 原始上下文
realtime_quote: 实时行情数据
chip_data: 筹码分布数据
trend_result: 趋势分析结果
stock_name: 股票名称
Returns:
增强后的上下文
"""
enhanced = context.copy()
# 添加股票名称
if stock_name:
enhanced['stock_name'] = stock_name
elif realtime_quote and realtime_quote.name:
enhanced['stock_name'] = realtime_quote.name
# 添加实时行情
if realtime_quote:
enhanced['realtime'] = {
'name': realtime_quote.name, # 股票名称
'price': realtime_quote.price,
'volume_ratio': realtime_quote.volume_ratio,
'volume_ratio_desc': self._describe_volume_ratio(realtime_quote.volume_ratio),
'turnover_rate': realtime_quote.turnover_rate,
'pe_ratio': realtime_quote.pe_ratio,
'pb_ratio': realtime_quote.pb_ratio,
'total_mv': realtime_quote.total_mv,
'circ_mv': realtime_quote.circ_mv,
'change_60d': realtime_quote.change_60d,
}
# 添加筹码分布
if chip_data:
current_price = realtime_quote.price if realtime_quote else 0
enhanced['chip'] = {
'profit_ratio': chip_data.profit_ratio,
'avg_cost': chip_data.avg_cost,
'concentration_90': chip_data.concentration_90,
'concentration_70': chip_data.concentration_70,
'chip_status': chip_data.get_chip_status(current_price),
}
# 添加趋势分析结果
if trend_result:
enhanced['trend_analysis'] = {
'trend_status': trend_result.trend_status.value,
'ma_alignment': trend_result.ma_alignment,
'trend_strength': trend_result.trend_strength,
'bias_ma5': trend_result.bias_ma5,
'bias_ma10': trend_result.bias_ma10,
'volume_status': trend_result.volume_status.value,
'volume_trend': trend_result.volume_trend,
'buy_signal': trend_result.buy_signal.value,
'signal_score': trend_result.signal_score,
'signal_reasons': trend_result.signal_reasons,
'risk_factors': trend_result.risk_factors,
}
return enhanced
def _describe_volume_ratio(self, volume_ratio: float) -> str:
"""
量比描述
量比 = 当前成交量 / 过去5日平均成交量
"""
if volume_ratio < 0.5:
return "极度萎缩"
elif volume_ratio < 0.8:
return "明显萎缩"
elif volume_ratio < 1.2:
return "正常"
elif volume_ratio < 2.0:
return "温和放量"
elif volume_ratio < 3.0:
return "明显放量"
else:
return "巨量"
def process_single_stock(
self,
code: str,
skip_analysis: bool = False,
single_stock_notify: bool = False,
report_type: ReportType = ReportType.SIMPLE
) -> Optional[AnalysisResult]:
"""
处理单只股票的完整流程
包括:
1. 获取数据
2. 保存数据
3. AI 分析
4. 单股推送(可选,#55
此方法会被线程池调用,需要处理好异常
Args:
code: 股票代码
skip_analysis: 是否跳过 AI 分析
single_stock_notify: 是否启用单股推送模式(每分析完一只立即推送)
report_type: 报告类型枚举从配置读取Issue #119
Returns:
AnalysisResult 或 None
"""
logger.info(f"========== 开始处理 {code} ==========")
try:
# Step 1: 获取并保存数据
success, error = self.fetch_and_save_stock_data(code)
if not success:
logger.warning(f"[{code}] 数据获取失败: {error}")
# 即使获取失败,也尝试用已有数据分析
# Step 2: AI 分析
if skip_analysis:
logger.info(f"[{code}] 跳过 AI 分析dry-run 模式)")
return None
result = self.analyze_stock(code)
if result:
logger.info(
f"[{code}] 分析完成: {result.operation_advice}, "
f"评分 {result.sentiment_score}"
)
# 单股推送模式(#55每分析完一只股票立即推送
if single_stock_notify and self.notifier.is_available():
try:
# 根据报告类型选择生成方法
if report_type == ReportType.FULL:
# 完整报告:使用决策仪表盘格式
report_content = self.notifier.generate_dashboard_report([result])
logger.info(f"[{code}] 使用完整报告格式")
else:
# 精简报告:使用单股报告格式(默认)
report_content = self.notifier.generate_single_stock_report(result)
logger.info(f"[{code}] 使用精简报告格式")
if self.notifier.send(report_content):
logger.info(f"[{code}] 单股推送成功")
else:
logger.warning(f"[{code}] 单股推送失败")
except Exception as e:
logger.error(f"[{code}] 单股推送异常: {e}")
return result
except Exception as e:
# 捕获所有异常,确保单股失败不影响整体
logger.exception(f"[{code}] 处理过程发生未知异常: {e}")
return None
def run(
self,
stock_codes: Optional[List[str]] = None,
dry_run: bool = False,
send_notification: bool = True
) -> List[AnalysisResult]:
"""
运行完整的分析流程
流程:
1. 获取待分析的股票列表
2. 使用线程池并发处理
3. 收集分析结果
4. 发送通知
Args:
stock_codes: 股票代码列表(可选,默认使用配置中的自选股)
dry_run: 是否仅获取数据不分析
send_notification: 是否发送推送通知
Returns:
分析结果列表
"""
start_time = time.time()
# 使用配置中的股票列表
if stock_codes is None:
self.config.refresh_stock_list()
stock_codes = self.config.stock_list
if not stock_codes:
logger.error("未配置自选股列表,请在 .env 文件中设置 STOCK_LIST")
return []
logger.info(f"===== 开始分析 {len(stock_codes)} 只股票 =====")
logger.info(f"股票列表: {', '.join(stock_codes)}")
logger.info(f"并发数: {self.max_workers}, 模式: {'仅获取数据' if dry_run else '完整分析'}")
# 单股推送模式(#55从配置读取
single_stock_notify = getattr(self.config, 'single_stock_notify', False)
# Issue #119: 从配置读取报告类型
report_type_str = getattr(self.config, 'report_type', 'simple').lower()
report_type = ReportType.FULL if report_type_str == 'full' else ReportType.SIMPLE
# Issue #128: 从配置读取分析间隔
analysis_delay = getattr(self.config, 'analysis_delay', 0)
if single_stock_notify:
logger.info(f"已启用单股推送模式:每分析完一只股票立即推送(报告类型: {report_type_str}")
results: List[AnalysisResult] = []
# 使用线程池并发处理
# 注意max_workers 设置较低默认3以避免触发反爬
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# 提交任务
future_to_code = {
executor.submit(
self.process_single_stock,
code,
skip_analysis=dry_run,
single_stock_notify=single_stock_notify and send_notification,
report_type=report_type # Issue #119: 传递报告类型
): code
for code in stock_codes
}
# 收集结果
for idx, future in enumerate(as_completed(future_to_code)):
code = future_to_code[future]
try:
result = future.result()
if result:
results.append(result)
# Issue #128: 个股之间添加延迟避免API限流
# 在非最后一只股票完成后添加延迟
if idx < len(stock_codes) - 1 and analysis_delay > 0:
logger.debug(f"等待 {analysis_delay} 秒后继续下一只股票...")
time.sleep(analysis_delay)
except Exception as e:
logger.error(f"[{code}] 任务执行失败: {e}")
# 统计
elapsed_time = time.time() - start_time
# dry-run 模式下,数据获取成功即视为成功
if dry_run:
# 检查哪些股票的数据今天已存在
success_count = sum(1 for code in stock_codes if self.db.has_today_data(code))
fail_count = len(stock_codes) - success_count
else:
success_count = len(results)
fail_count = len(stock_codes) - success_count
logger.info(f"===== 分析完成 =====")
logger.info(f"成功: {success_count}, 失败: {fail_count}, 耗时: {elapsed_time:.2f}")
# 发送通知(单股推送模式下跳过汇总推送,避免重复)
if results and send_notification and not dry_run:
if single_stock_notify:
# 单股推送模式:只保存汇总报告,不再重复推送
logger.info("单股推送模式:跳过汇总推送,仅保存报告到本地")
self._send_notifications(results, skip_push=True)
else:
self._send_notifications(results)
return results
def _send_notifications(self, results: List[AnalysisResult], skip_push: bool = False) -> None:
"""
发送分析结果通知
生成决策仪表盘格式的报告
Args:
results: 分析结果列表
skip_push: 是否跳过推送(仅保存到本地,用于单股推送模式)
"""
try:
logger.info("生成决策仪表盘日报...")
# 生成决策仪表盘格式的详细日报
report = self.notifier.generate_dashboard_report(results)
# 保存到本地
filepath = self.notifier.save_report_to_file(report)
logger.info(f"决策仪表盘日报已保存: {filepath}")
# 跳过推送(单股推送模式)
if skip_push:
return
# 推送通知
if self.notifier.is_available():
channels = self.notifier.get_available_channels()
context_success = self.notifier.send_to_context(report)
# 企业微信:只发精简版(平台限制)
wechat_success = False
if NotificationChannel.WECHAT in channels:
dashboard_content = self.notifier.generate_wechat_dashboard(results)
logger.info(f"企业微信仪表盘长度: {len(dashboard_content)} 字符")
logger.debug(f"企业微信推送内容:\n{dashboard_content}")
wechat_success = self.notifier.send_to_wechat(dashboard_content)
# 其他渠道:发完整报告(避免自定义 Webhook 被 wechat 截断逻辑污染)
non_wechat_success = False
for channel in channels:
if channel == NotificationChannel.WECHAT:
continue
if channel == NotificationChannel.FEISHU:
non_wechat_success = self.notifier.send_to_feishu(report) or non_wechat_success
elif channel == NotificationChannel.TELEGRAM:
non_wechat_success = self.notifier.send_to_telegram(report) or non_wechat_success
elif channel == NotificationChannel.EMAIL:
non_wechat_success = self.notifier.send_to_email(report) or non_wechat_success
elif channel == NotificationChannel.CUSTOM:
non_wechat_success = self.notifier.send_to_custom(report) or non_wechat_success
else:
logger.warning(f"未知通知渠道: {channel}")
success = wechat_success or non_wechat_success or context_success
if success:
logger.info("决策仪表盘推送成功")
else:
logger.warning("决策仪表盘推送失败")
else:
logger.info("通知渠道未配置,跳过推送")
except Exception as e:
logger.error(f"发送通知失败: {e}")
def parse_arguments() -> argparse.Namespace:
"""解析命令行参数"""
parser = argparse.ArgumentParser(
description='A股自选股智能分析系统',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
示例:
python main.py # 正常运行
python main.py --debug # 调试模式
python main.py --dry-run # 仅获取数据,不进行 AI 分析
python main.py --stocks 600519,000001 # 指定分析特定股票
python main.py --no-notify # 不发送推送通知
python main.py --single-notify # 启用单股推送模式(每分析完一只立即推送)
python main.py --schedule # 启用定时任务模式
python main.py --market-review # 仅运行大盘复盘
'''
)
parser.add_argument(
'--debug',
action='store_true',
help='启用调试模式,输出详细日志'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='仅获取数据,不进行 AI 分析'
)
parser.add_argument(
'--stocks',
type=str,
help='指定要分析的股票代码,逗号分隔(覆盖配置文件)'
)
parser.add_argument(
'--no-notify',
action='store_true',
help='不发送推送通知'
)
parser.add_argument(
'--single-notify',
action='store_true',
help='启用单股推送模式:每分析完一只股票立即推送,而不是汇总推送'
)
parser.add_argument(
'--workers',
type=int,
default=None,
help='并发线程数(默认使用配置值)'
)
parser.add_argument(
'--schedule',
action='store_true',
help='启用定时任务模式,每日定时执行'
)
parser.add_argument(
'--market-review',
action='store_true',
help='仅运行大盘复盘分析'
)
parser.add_argument(
'--no-market-review',
action='store_true',
help='跳过大盘复盘分析'
)
parser.add_argument(
'--webui',
action='store_true',
help='启动本地配置 WebUI'
)
parser.add_argument(
'--webui-only',
action='store_true',
help='仅启动 WebUI 服务,不自动执行分析(通过 /analysis API 手动触发)'
)
return parser.parse_args()
def run_market_review(notifier: NotificationService, analyzer=None, search_service=None) -> Optional[str]:
"""
执行大盘复盘分析
Args:
notifier: 通知服务
analyzer: AI分析器可选
search_service: 搜索服务(可选)
Returns:
复盘报告文本
"""
logger.info("开始执行大盘复盘分析...")
try:
market_analyzer = MarketAnalyzer(
search_service=search_service,
analyzer=analyzer
)
# 执行复盘
review_report = market_analyzer.run_daily_review()
if review_report:
# 保存报告到文件
date_str = datetime.now().strftime('%Y%m%d')
report_filename = f"market_review_{date_str}.md"
filepath = notifier.save_report_to_file(
f"# 🎯 大盘复盘\n\n{review_report}",
report_filename
)
logger.info(f"大盘复盘报告已保存: {filepath}")
# 推送通知
if notifier.is_available():
# 添加标题
report_content = f"🎯 大盘复盘\n\n{review_report}"
success = notifier.send(report_content)
if success:
logger.info("大盘复盘推送成功")
else:
logger.warning("大盘复盘推送失败")
return review_report
except Exception as e:
logger.error(f"大盘复盘分析失败: {e}")
return None
def run_full_analysis(
config: Config,
args: argparse.Namespace,
stock_codes: Optional[List[str]] = None
):
"""
执行完整的分析流程(个股 + 大盘复盘)
这是定时任务调用的主函数
"""
try:
# 命令行参数 --single-notify 覆盖配置(#55
if getattr(args, 'single_notify', False):
config.single_stock_notify = True
# 创建调度器
pipeline = StockAnalysisPipeline(
config=config,
max_workers=args.workers
)
# 1. 运行个股分析
results = pipeline.run(
stock_codes=stock_codes,
dry_run=args.dry_run,
send_notification=not args.no_notify
)
# Issue #128: 分析间隔 - 在个股分析和大盘分析之间添加延迟
analysis_delay = getattr(config, 'analysis_delay', 0)
if analysis_delay > 0 and config.market_review_enabled and not args.no_market_review:
logger.info(f"等待 {analysis_delay} 秒后执行大盘复盘避免API限流...")
time.sleep(analysis_delay)
# 2. 运行大盘复盘(如果启用且不是仅个股模式)
market_report = ""
if config.market_review_enabled and not args.no_market_review:
# 只调用一次,并获取结果
review_result = run_market_review(
notifier=pipeline.notifier,
analyzer=pipeline.analyzer,
search_service=pipeline.search_service
)
# 如果有结果,赋值给 market_report 用于后续飞书文档生成
if review_result:
market_report = review_result
# 输出摘要
if results:
logger.info("\n===== 分析结果摘要 =====")
for r in sorted(results, key=lambda x: x.sentiment_score, reverse=True):
emoji = r.get_emoji()
logger.info(
f"{emoji} {r.name}({r.code}): {r.operation_advice} | "
f"评分 {r.sentiment_score} | {r.trend_prediction}"
)
logger.info("\n任务执行完成")
# === 新增:生成飞书云文档 ===
try:
feishu_doc = FeishuDocManager()
if feishu_doc.is_configured() and (results or market_report):
logger.info("正在创建飞书云文档...")
# 1. 准备标题 "01-01 13:01大盘复盘"
tz_cn = timezone(timedelta(hours=8))
now = datetime.now(tz_cn)
doc_title = f"{now.strftime('%Y-%m-%d %H:%M')} 大盘复盘"
# 2. 准备内容 (拼接个股分析和大盘复盘)
full_content = ""
# 添加大盘复盘内容(如果有)
if market_report:
full_content += f"# 📈 大盘复盘\n\n{market_report}\n\n---\n\n"
# 添加个股决策仪表盘(使用 NotificationService 生成)
if results:
dashboard_content = pipeline.notifier.generate_dashboard_report(results)
full_content += f"# 🚀 个股决策仪表盘\n\n{dashboard_content}"
# 3. 创建文档
doc_url = feishu_doc.create_daily_doc(doc_title, full_content)
if doc_url:
logger.info(f"飞书云文档创建成功: {doc_url}")
# 可选:将文档链接也推送到群里
pipeline.notifier.send(f"[{now.strftime('%Y-%m-%d %H:%M')}] 复盘文档创建成功: {doc_url}")
except Exception as e:
logger.error(f"飞书文档生成失败: {e}")
except Exception as e:
logger.exception(f"分析流程执行失败: {e}")
def start_bot_stream_clients(config: Config) -> None:
"""Start bot stream clients when enabled in config."""
# 启动钉钉 Stream 客户端
if config.dingtalk_stream_enabled:
try:
from bot.platforms import start_dingtalk_stream_background, DINGTALK_STREAM_AVAILABLE
if DINGTALK_STREAM_AVAILABLE:
if start_dingtalk_stream_background():
logger.info("[Main] Dingtalk Stream client started in background.")
else:
logger.warning("[Main] Dingtalk Stream client failed to start.")
else:
logger.warning("[Main] Dingtalk Stream enabled but SDK is missing.")
logger.warning("[Main] Run: pip install dingtalk-stream")
except Exception as exc:
logger.error(f"[Main] Failed to start Dingtalk Stream client: {exc}")
# 启动飞书 Stream 客户端
if getattr(config, 'feishu_stream_enabled', False):
try:
from bot.platforms import start_feishu_stream_background, FEISHU_SDK_AVAILABLE
if FEISHU_SDK_AVAILABLE:
if start_feishu_stream_background():
logger.info("[Main] Feishu Stream client started in background.")
else:
logger.warning("[Main] Feishu Stream client failed to start.")
else:
logger.warning("[Main] Feishu Stream enabled but SDK is missing.")
logger.warning("[Main] Run: pip install lark-oapi")
except Exception as exc:
logger.error(f"[Main] Failed to start Feishu Stream client: {exc}")
def main() -> int:
"""
主入口函数
Returns:
退出码0 表示成功)
"""
# 解析命令行参数
args = parse_arguments()
# 加载配置(在设置日志前加载,以获取日志目录)
config = get_config()
# 配置日志(输出到控制台和文件)
setup_logging(debug=args.debug, log_dir=config.log_dir)
logger.info("=" * 60)
logger.info("A股自选股智能分析系统 启动")
logger.info(f"运行时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
logger.info("=" * 60)
# 验证配置
warnings = config.validate()
for warning in warnings:
logger.warning(warning)
# 解析股票列表
stock_codes = None
if args.stocks:
stock_codes = [code.strip() for code in args.stocks.split(',') if code.strip()]
logger.info(f"使用命令行指定的股票列表: {stock_codes}")
# === 启动 WebUI (如果启用) ===
# 优先级: 命令行参数 > 配置文件
start_webui = (args.webui or args.webui_only or config.webui_enabled) and os.getenv("GITHUB_ACTIONS") != "true"
if start_webui:
try:
from webui import run_server_in_thread
run_server_in_thread(host=config.webui_host, port=config.webui_port)
start_bot_stream_clients(config)
except Exception as e:
logger.error(f"启动 WebUI 失败: {e}")
# === 仅 WebUI 模式:不自动执行分析 ===
if args.webui_only:
logger.info("模式: 仅 WebUI 服务")
logger.info(f"WebUI 运行中: http://{config.webui_host}:{config.webui_port}")
logger.info("通过 /analysis?code=xxx 接口手动触发分析")
logger.info("按 Ctrl+C 退出...")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("\n用户中断,程序退出")
return 0
try:
# 模式1: 仅大盘复盘
if args.market_review:
logger.info("模式: 仅大盘复盘")
notifier = NotificationService()
# 初始化搜索服务和分析器(如果有配置)
search_service = None
analyzer = None
if config.bocha_api_keys or config.tavily_api_keys or config.serpapi_keys:
search_service = SearchService(
bocha_keys=config.bocha_api_keys,
tavily_keys=config.tavily_api_keys,
serpapi_keys=config.serpapi_keys
)
if config.gemini_api_key:
analyzer = GeminiAnalyzer(api_key=config.gemini_api_key)
run_market_review(notifier, analyzer, search_service)
return 0
# 模式2: 定时任务模式
if args.schedule or config.schedule_enabled:
logger.info("模式: 定时任务")
logger.info(f"每日执行时间: {config.schedule_time}")
from scheduler import run_with_schedule
def scheduled_task():
run_full_analysis(config, args, stock_codes)
run_with_schedule(
task=scheduled_task,
schedule_time=config.schedule_time,
run_immediately=True # 启动时先执行一次
)
return 0
# 模式3: 正常单次运行
run_full_analysis(config, args, stock_codes)
logger.info("\n程序执行完成")
# 如果启用了 WebUI 且是非定时任务模式,保持程序运行以便访问 WebUI
if start_webui and not (args.schedule or config.schedule_enabled):
logger.info("WebUI 运行中 (按 Ctrl+C 退出)...")
try:
# 简单的保持活跃循环
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
return 0
except KeyboardInterrupt:
logger.info("\n用户中断,程序退出")
return 130
except Exception as e:
logger.exception(f"程序执行失败: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())