commit 4a003c94ea7047dcb52e5b767d7c42f62b7a533f Author: zhulinsen Date: Sat Jan 10 15:38:56 2026 +0800 Initial commit: A股智能分析系统 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..56e3a53a0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +# 忽略 Python 缓存 +__pycache__/ +*.py[cod] +*$py.class +*.so + +# 忽略虚拟环境 +venv/ +.venv/ +env/ +.env.local + +# 忽略 IDE +.idea/ +.vscode/ +*.swp +*.swo + +# 忽略数据文件(可选,如果想持久化就注释掉) +# data/ +# logs/ +# reports/ + +# 忽略测试文件 +test_*.py +*_test.py + +# 忽略文档 +*.md +!README.md diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..ec6ad070c --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# =================================== +# A股自选股智能分析系统 - 环境变量配置模板 +# 复制此文件为 .env 并填入真实配置 +# =================================== + +# 自选股列表(逗号分隔,支持沪深两市代码) +# 沪市:600xxx, 601xxx, 603xxx +# 深市:000xxx, 002xxx, 300xxx +STOCK_LIST=600519,300750,002594 + +# 数据源配置 +# Tushare Pro Token(可选,从 https://tushare.pro 获取) +TUSHARE_TOKEN=your_tushare_token_here + +# AI 分析配置 +# Gemini API Key(从 Google AI Studio 获取) +GEMINI_API_KEY=your_gemini_api_key_here +# Gemini 模型名称 +GEMINI_MODEL=gemini-3-flash-preview +GEMINI_MODEL_FALLBACK=gemini-2.5-flash +# Gemini API 请求间隔(秒,防止限流) +GEMINI_REQUEST_DELAY=2.0 + +# 搜索引擎配置(用于获取股票新闻) +# Tavily API Keys(支持多个,逗号分隔) +TAVILY_API_KEYS=your_tavily_key_here +# SerpAPI Keys(支持多个,逗号分隔) +SERPAPI_KEYS=your_serpapi_key_here + +# 企业微信机器人 Webhook URL(用于推送日报) +WECHAT_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your_key_here + +# 数据库路径 +DATABASE_PATH=./data/stock_analysis.db + +# === 定时任务配置 === +# 是否启用定时任务(true/false) +SCHEDULE_ENABLED=false +# 每日执行时间(HH:MM 格式,24小时制) +SCHEDULE_TIME=18:00 +# 是否启用大盘复盘(true/false) +MARKET_REVIEW_ENABLED=true + +# 系统配置 +# 日志目录 +LOG_DIR=./logs +# 日志级别(DEBUG/INFO/WARNING/ERROR) +LOG_LEVEL=INFO +# 最大并发线程数(建议保持低并发防封禁) +MAX_WORKERS=3 +# 是否启用调试日志 +DEBUG=false diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..aba3596b6 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,6 @@ +# 如果你想支持这个项目,可以通过以下方式: +# github: [ZhuLinsen] +# patreon: +# open_collective: +# ko_fi: +# custom: ['https://example.com/donate'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..ffac4eafa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug 报告 +about: 报告一个问题帮助我们改进 +title: '[Bug] ' +labels: bug +assignees: '' +--- + +## 问题描述 +简明扼要地描述遇到的问题。 + +## 复现步骤 +1. 执行命令 '...' +2. 配置 '...' +3. 查看 '...' +4. 出现错误 + +## 期望行为 +描述你期望发生的情况。 + +## 实际行为 +描述实际发生的情况。 + +## 错误日志 +``` +粘贴相关错误日志 +``` + +## 环境信息 +- 操作系统: [例如 Ubuntu 22.04] +- Python 版本: [例如 3.11] +- 运行方式: [本地/Docker/GitHub Actions] +- 相关配置: + - GEMINI_MODEL: + - 数据源: + +## 其他信息 +添加任何其他有关问题的信息或截图。 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..e1d08f91e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: 💬 讨论区 + url: https://github.com/ZhuLinsen/daily_stock_analysis/discussions + about: 有问题想讨论?欢迎来讨论区交流 + - name: 📖 使用文档 + url: https://github.com/ZhuLinsen/daily_stock_analysis#-快速开始 + about: 查看 README 获取使用帮助 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..b01ace383 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: 功能建议 +about: 提出新功能或改进建议 +title: '[Feature] ' +labels: enhancement +assignees: '' +--- + +## 功能描述 +简明扼要地描述你希望增加的功能。 + +## 使用场景 +描述在什么情况下需要这个功能。 + +## 期望实现 +描述你期望这个功能如何工作。 + +## 备选方案 +描述你考虑过的其他替代方案。 + +## 相关信息 +- 是否愿意贡献代码实现: [是/否] +- 参考链接/文档: +- 其他说明: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..8e27b0b86 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,39 @@ +## 变更类型 + +- [ ] 🐛 Bug 修复 +- [ ] ✨ 新功能 +- [ ] 📝 文档更新 +- [ ] 🎨 代码优化/重构 +- [ ] ⚡ 性能优化 +- [ ] 🔧 配置/构建相关 + +## 变更描述 + +简要描述这个 PR 做了什么。 + +## 关联 Issue + +关联的 Issue 编号(如有):fixes # + +## 测试说明 + +描述如何测试这些变更: + +1. 步骤一 +2. 步骤二 +3. ... + +## 检查清单 + +- [ ] 代码符合项目规范 +- [ ] 已添加必要的注释/文档 +- [ ] 已在本地测试通过 +- [ ] 已更新相关文档(如需要) + +## 截图(如适用) + +如有 UI 变更,请附上截图。 + +## 其他说明 + +其他需要说明的内容。 diff --git a/.github/workflows/daily_analysis.yml b/.github/workflows/daily_analysis.yml new file mode 100644 index 000000000..1888be2cd --- /dev/null +++ b/.github/workflows/daily_analysis.yml @@ -0,0 +1,106 @@ +name: 每日股票分析 + +on: + # 定时触发 - 每天北京时间 18:00 (UTC 10:00) + schedule: + - cron: '0 10 * * 1-5' # 周一到周五,UTC 10:00 = 北京时间 18:00 + + # 手动触发 + workflow_dispatch: + inputs: + mode: + description: '运行模式' + required: true + default: 'full' + type: choice + options: + - full # 完整分析(股票+大盘) + - market-only # 仅大盘复盘 + - stocks-only # 仅股票分析 + +jobs: + analyze: + runs-on: ubuntu-latest + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 设置 Python 环境 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: 安装依赖 + run: | + pip install --upgrade pip + pip install -r requirements.txt + + - name: 创建必要目录 + run: | + mkdir -p data logs reports + + - name: 执行股票分析 + env: + # Gemini AI + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GEMINI_MODEL: ${{ secrets.GEMINI_MODEL || 'gemini-3-flash-preview' }} + GEMINI_MODEL_FALLBACK: ${{ secrets.GEMINI_MODEL_FALLBACK || 'gemini-2.5-flash' }} + GEMINI_REQUEST_DELAY: '3.0' # GitHub Actions 建议增加延时 + + # 数据源 (可选) + TUSHARE_TOKEN: ${{ secrets.TUSHARE_TOKEN }} + + # 搜索服务 + TAVILY_API_KEYS: ${{ secrets.TAVILY_API_KEYS }} + SERPAPI_API_KEYS: ${{ secrets.SERPAPI_API_KEYS }} + + # 企业微信通知 + WECHAT_WEBHOOK_URL: ${{ secrets.WECHAT_WEBHOOK_URL }} + + # 自选股列表 (从 secrets 或使用默认值) + STOCK_LIST: ${{ secrets.STOCK_LIST || '600519' }} + + # 其他配置 + LOG_LEVEL: INFO + DATA_DAYS: 60 + MAX_CONCURRENT: 3 + run: | + # 判断运行模式 + MODE="${{ github.event.inputs.mode || 'full' }}" + + echo "==========================================" + echo "运行模式: $MODE" + echo "自选股: $STOCK_LIST" + echo "时间: $(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S')" + echo "==========================================" + + if [ "$MODE" = "market-only" ]; then + python main.py --market-review + elif [ "$MODE" = "stocks-only" ]; then + python main.py --no-market-review + else + python main.py + fi + + - name: 上传分析报告 + uses: actions/upload-artifact@v4 + if: always() + with: + name: analysis-reports-${{ github.run_number }} + path: | + reports/ + logs/ + retention-days: 30 + + - name: 显示运行结果 + if: always() + run: | + echo "==========================================" + echo "分析完成" + echo "==========================================" + if [ -d "reports" ]; then + echo "生成的报告:" + ls -la reports/ + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..3dd64fd59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# 环境变量文件(包含敏感信息) +.env +.env.local +.env.*.local + +# 测试文件(可能包含敏感配置) +test_*.py +!test_env.py + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# 虚拟环境 +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# 数据和日志 +data/ +logs/ +reports/ +*.db +*.sqlite +*.sqlite3 + +# 系统文件 +.DS_Store +Thumbs.db + +# 测试 +.pytest_cache/ +.coverage +htmlcov/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..81e2df7ba --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +所有重要更改都会记录在此文件中。 + +格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), +版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。 + +## [Unreleased] + +### 计划中 +- 钉钉机器人支持 +- 飞书机器人支持 +- Web 管理界面 + +## [1.0.0] - 2026-01-10 + +### 新增 +- 🎯 AI 决策仪表盘分析 + - 一句话核心结论 + - 精确买入/止损/目标点位 + - 检查清单(✅⚠️❌) + - 分持仓建议(空仓者 vs 持仓者) +- 📊 大盘复盘功能 + - 主要指数行情 + - 涨跌统计 + - 板块涨跌榜 + - AI 生成复盘报告 +- 🔍 多数据源支持 + - AkShare(主数据源,免费) + - Tushare Pro + - Baostock + - YFinance +- 📰 新闻搜索服务 + - Tavily API + - SerpAPI +- 💬 企业微信机器人推送 +- ⏰ 定时任务调度 +- 🐳 Docker 部署支持 +- 🚀 GitHub Actions 零成本部署 + +### 技术特性 +- Gemini AI 模型(gemini-3-flash-preview) +- 429 限流自动重试 + 模型切换 +- 请求间延时防封禁 +- 多 API Key 负载均衡 +- SQLite 本地数据存储 + +--- + +[Unreleased]: https://github.com/ZhuLinsen/daily_stock_analysis/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/ZhuLinsen/daily_stock_analysis/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..f7f38a85a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# 贡献指南 + +感谢你对本项目的关注!欢迎任何形式的贡献。 + +## 🐛 报告 Bug + +1. 先搜索 [Issues](https://github.com/ZhuLinsen/daily_stock_analysis/issues) 确认问题未被报告 +2. 使用 Bug Report 模板创建新 Issue +3. 提供详细的复现步骤和环境信息 + +## 💡 功能建议 + +1. 先搜索 Issues 确认建议未被提出 +2. 使用 Feature Request 模板创建新 Issue +3. 详细描述你的使用场景和期望功能 + +## 🔧 提交代码 + +### 开发环境 + +```bash +# 克隆仓库 +git clone https://github.com/ZhuLinsen/daily_stock_analysis.git +cd daily_stock_analysis + +# 创建虚拟环境 +python -m venv venv +source venv/bin/activate # Linux/Mac +# venv\Scripts\activate # Windows + +# 安装依赖 +pip install -r requirements.txt + +# 配置环境变量 +cp .env.example .env +``` + +### 提交流程 + +1. Fork 本仓库 +2. 创建特性分支:`git checkout -b feature/your-feature` +3. 提交改动:`git commit -m 'feat: add some feature'` +4. 推送分支:`git push origin feature/your-feature` +5. 创建 Pull Request + +### Commit 规范 + +使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范: + +``` +feat: 新功能 +fix: Bug 修复 +docs: 文档更新 +style: 代码格式(不影响功能) +refactor: 重构 +perf: 性能优化 +test: 测试相关 +chore: 构建/工具相关 +``` + +示例: +``` +feat: 添加钉钉机器人支持 +fix: 修复 429 限流重试逻辑 +docs: 更新 README 部署说明 +``` + +### 代码规范 + +- Python 代码遵循 PEP 8 +- 函数和类需要添加 docstring +- 重要逻辑添加注释 +- 新功能需要更新相关文档 + +## 📋 优先贡献方向 + +查看 [Roadmap](README.md#-roadmap) 了解当前需要的功能: + +- 🔔 新通知渠道(钉钉、飞书、Telegram) +- 🤖 新 AI 模型支持(GPT-4、Claude) +- 📊 新数据源接入 +- 🐛 Bug 修复和性能优化 +- 📖 文档完善和翻译 + +## ❓ 问题解答 + +如有任何问题,欢迎: +- 创建 Issue 讨论 +- 查看已有 Issue 和 Discussion + +再次感谢你的贡献! 🎉 diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 000000000..dcbeea305 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,430 @@ +# 🚀 部署指南 + +本文档介绍如何将 A股自选股智能分析系统部署到服务器。 + +## 📋 部署方案对比 + +| 方案 | 优点 | 缺点 | 推荐场景 | +|------|------|------|----------| +| **Docker Compose** ⭐ | 一键部署、环境隔离、易迁移、易升级 | 需要安装 Docker | **推荐**:大多数场景 | +| **直接部署** | 简单直接、无额外依赖 | 环境依赖、迁移麻烦 | 临时测试 | +| **Systemd 服务** | 系统级管理、开机自启 | 配置繁琐 | 长期稳定运行 | +| **Supervisor** | 进程管理、自动重启 | 需要额外安装 | 多进程管理 | + +**结论:推荐使用 Docker Compose,迁移最快最方便!** + +--- + +## 🐳 方案一:Docker Compose 部署(推荐) + +### 1. 安装 Docker + +```bash +# Ubuntu/Debian +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER + +# CentOS +sudo yum install -y docker docker-compose +sudo systemctl start docker +sudo systemctl enable docker +``` + +### 2. 准备配置文件 + +```bash +# 克隆代码(或上传代码到服务器) +git clone /opt/stock-analyzer +cd /opt/stock-analyzer + +# 复制并编辑配置文件 +cp .env.example .env +vim .env # 填入真实的 API Key 等配置 +``` + +### 3. 一键启动 + +```bash +# 构建并启动 +docker-compose up -d + +# 查看日志 +docker-compose logs -f + +# 查看运行状态 +docker-compose ps +``` + +### 4. 常用管理命令 + +```bash +# 停止服务 +docker-compose down + +# 重启服务 +docker-compose restart + +# 更新代码后重新部署 +git pull +docker-compose build --no-cache +docker-compose up -d + +# 进入容器调试 +docker-compose exec stock-analyzer bash + +# 手动执行一次分析 +docker-compose exec stock-analyzer python main.py --no-notify +``` + +### 5. 数据持久化 + +数据自动保存在宿主机目录: +- `./data/` - 数据库文件 +- `./logs/` - 日志文件 +- `./reports/` - 分析报告 + +--- + +## 🖥️ 方案二:直接部署 + +### 1. 安装 Python 环境 + +```bash +# 安装 Python 3.10+ +sudo apt update +sudo apt install -y python3.10 python3.10-venv python3-pip + +# 创建虚拟环境 +python3.10 -m venv /opt/stock-analyzer/venv +source /opt/stock-analyzer/venv/bin/activate +``` + +### 2. 安装依赖 + +```bash +cd /opt/stock-analyzer +pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple +``` + +### 3. 配置环境变量 + +```bash +cp .env.example .env +vim .env # 填入配置 +``` + +### 4. 运行 + +```bash +# 单次运行 +python main.py + +# 定时任务模式(前台运行) +python main.py --schedule + +# 后台运行(使用 nohup) +nohup python main.py --schedule > /dev/null 2>&1 & +``` + +--- + +## 🔧 方案三:Systemd 服务 + +创建 systemd 服务文件实现开机自启和自动重启: + +### 1. 创建服务文件 + +```bash +sudo vim /etc/systemd/system/stock-analyzer.service +``` + +内容: +```ini +[Unit] +Description=A股自选股智能分析系统 +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/stock-analyzer +Environment="PATH=/opt/stock-analyzer/venv/bin" +ExecStart=/opt/stock-analyzer/venv/bin/python main.py --schedule +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target +``` + +### 2. 启动服务 + +```bash +# 重载配置 +sudo systemctl daemon-reload + +# 启动服务 +sudo systemctl start stock-analyzer + +# 开机自启 +sudo systemctl enable stock-analyzer + +# 查看状态 +sudo systemctl status stock-analyzer + +# 查看日志 +journalctl -u stock-analyzer -f +``` + +--- + +## ⚙️ 配置说明 + +### 必须配置项 + +| 配置项 | 说明 | 获取方式 | +|--------|------|----------| +| `GEMINI_API_KEY` | AI 分析必需 | [Google AI Studio](https://aistudio.google.com/) | +| `STOCK_LIST` | 自选股列表 | 逗号分隔的股票代码 | +| `WECHAT_WEBHOOK_URL` | 微信推送 | 企业微信群机器人 | + +### 可选配置项 + +| 配置项 | 默认值 | 说明 | +|--------|--------|------| +| `SCHEDULE_ENABLED` | `false` | 是否启用定时任务 | +| `SCHEDULE_TIME` | `18:00` | 每日执行时间 | +| `MARKET_REVIEW_ENABLED` | `true` | 是否启用大盘复盘 | +| `TAVILY_API_KEYS` | - | 新闻搜索(可选) | + +--- + +## 🌐 代理配置 + +如果服务器在国内,访问 Gemini API 需要代理: + +### Docker 方式 + +编辑 `docker-compose.yml`: +```yaml +environment: + - http_proxy=http://your-proxy:port + - https_proxy=http://your-proxy:port +``` + +### 直接部署方式 + +编辑 `main.py` 顶部: +```python +os.environ["http_proxy"] = "http://your-proxy:port" +os.environ["https_proxy"] = "http://your-proxy:port" +``` + +--- + +## 📊 监控与维护 + +### 日志查看 + +```bash +# Docker 方式 +docker-compose logs -f --tail=100 + +# 直接部署 +tail -f /opt/stock-analyzer/logs/stock_analysis_*.log +``` + +### 健康检查 + +```bash +# 检查进程 +ps aux | grep main.py + +# 检查最近的报告 +ls -la /opt/stock-analyzer/reports/ +``` + +### 定期维护 + +```bash +# 清理旧日志(保留7天) +find /opt/stock-analyzer/logs -mtime +7 -delete + +# 清理旧报告(保留30天) +find /opt/stock-analyzer/reports -mtime +30 -delete +``` + +--- + +## ❓ 常见问题 + +### 1. Docker 构建失败 + +```bash +# 清理缓存重新构建 +docker-compose build --no-cache +``` + +### 2. API 访问超时 + +检查代理配置,确保服务器能访问 Gemini API。 + +### 3. 数据库锁定 + +```bash +# 停止服务后删除 lock 文件 +rm /opt/stock-analyzer/data/*.lock +``` + +### 4. 内存不足 + +调整 `docker-compose.yml` 中的内存限制: +```yaml +deploy: + resources: + limits: + memory: 1G +``` + +--- + +## 🔄 快速迁移 + +从一台服务器迁移到另一台: + +```bash +# 源服务器:打包 +cd /opt/stock-analyzer +tar -czvf stock-analyzer-backup.tar.gz .env data/ logs/ reports/ + +# 目标服务器:部署 +mkdir -p /opt/stock-analyzer +cd /opt/stock-analyzer +git clone . +tar -xzvf stock-analyzer-backup.tar.gz +docker-compose up -d +``` + +--- + +## ☁️ 方案四:GitHub Actions 部署(免服务器) + +**最简单的方案!** 无需服务器,利用 GitHub 免费计算资源。 + +### 优势 +- ✅ **完全免费**(每月 2000 分钟) +- ✅ **无需服务器** +- ✅ **自动定时执行** +- ✅ **零维护成本** + +### 限制 +- ⚠️ 无状态(每次运行是新环境) +- ⚠️ 定时可能有几分钟延迟 +- ⚠️ 无法提供 HTTP API + +### 部署步骤 + +#### 1. 创建 GitHub 仓库 + +```bash +# 初始化 git(如果还没有) +cd /path/to/daily_stock_analysis +git init +git add . +git commit -m "Initial commit" + +# 创建 GitHub 仓库并推送 +# 在 GitHub 网页上创建新仓库后: +git remote add origin https://github.com/你的用户名/daily_stock_analysis.git +git branch -M main +git push -u origin main +``` + +#### 2. 配置 Secrets(重要!) + +打开仓库页面 → **Settings** → **Secrets and variables** → **Actions** → **New repository secret** + +添加以下 Secrets: + +| Secret 名称 | 说明 | 必填 | +|------------|------|------| +| `GEMINI_API_KEY` | Gemini AI API Key | ✅ | +| `WECHAT_WEBHOOK_URL` | 企业微信机器人 Webhook | ✅ | +| `STOCK_LIST` | 自选股列表,如 `600519,300750` | ✅ | +| `TAVILY_API_KEYS` | Tavily 搜索 API Key | 推荐 | +| `SERPAPI_API_KEYS` | SerpAPI Key | 可选 | +| `TUSHARE_TOKEN` | Tushare Token | 可选 | +| `GEMINI_MODEL` | 模型名称(默认 gemini-2.0-flash) | 可选 | + +#### 3. 验证 Workflow 文件 + +确保 `.github/workflows/daily_analysis.yml` 文件存在且已提交: + +```bash +git add .github/workflows/daily_analysis.yml +git commit -m "Add GitHub Actions workflow" +git push +``` + +#### 4. 手动测试运行 + +1. 打开仓库页面 → **Actions** 标签 +2. 选择 **"每日股票分析"** workflow +3. 点击 **"Run workflow"** 按钮 +4. 选择运行模式: + - `full` - 完整分析(股票+大盘) + - `market-only` - 仅大盘复盘 + - `stocks-only` - 仅股票分析 +5. 点击绿色 **"Run workflow"** 按钮 + +#### 5. 查看执行日志 + +- Actions 页面可以看到运行历史 +- 点击具体的运行记录查看详细日志 +- 分析报告会作为 Artifact 保存 30 天 + +### 定时说明 + +默认配置:**周一到周五,北京时间 18:00** 自动执行 + +修改时间:编辑 `.github/workflows/daily_analysis.yml` 中的 cron 表达式: + +```yaml +schedule: + - cron: '0 10 * * 1-5' # UTC 时间,+8 = 北京时间 +``` + +常用 cron 示例: +| 表达式 | 说明 | +|--------|------| +| `'0 10 * * 1-5'` | 周一到周五 18:00(北京时间) | +| `'30 7 * * 1-5'` | 周一到周五 15:30(北京时间) | +| `'0 10 * * *'` | 每天 18:00(北京时间) | +| `'0 2 * * 1-5'` | 周一到周五 10:00(北京时间) | + +### 修改自选股 + +方法一:修改仓库 Secret `STOCK_LIST` + +方法二:直接修改代码后推送: +```bash +# 修改 .env.example 或在代码中设置默认值 +git commit -am "Update stock list" +git push +``` + +### 常见问题 + +**Q: 为什么定时任务没有执行?** +A: GitHub Actions 定时任务可能有 5-15 分钟延迟,且仅在仓库有活动时才触发。长时间无 commit 可能导致 workflow 被禁用。 + +**Q: 如何查看历史报告?** +A: Actions → 选择运行记录 → Artifacts → 下载 `analysis-reports-xxx` + +**Q: 免费额度够用吗?** +A: 每次运行约 2-5 分钟,一个月 22 个工作日 = 44-110 分钟,远低于 2000 分钟限制。 + +--- + +**祝部署顺利!🎉** + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..1826b5c52 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# =================================== +# A股自选股智能分析系统 - Docker 镜像 +# =================================== +# 基于 Python 3.11 slim 镜像,体积小、启动快 + +FROM python:3.11-slim + +# 设置工作目录 +WORKDIR /app + +# 设置时区为上海 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# 安装系统依赖 +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# 复制依赖文件 +COPY requirements.txt . + +# 安装 Python 依赖 +RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +# 复制应用代码 +COPY *.py ./ +COPY data_provider/ ./data_provider/ + +# 创建数据目录 +RUN mkdir -p /app/data /app/logs /app/reports + +# 设置环境变量默认值 +ENV PYTHONUNBUFFERED=1 +ENV LOG_DIR=/app/logs +ENV DATABASE_PATH=/app/data/stock_analysis.db + +# 数据卷(持久化数据) +VOLUME ["/app/data", "/app/logs", "/app/reports"] + +# 健康检查 +HEALTHCHECK --interval=5m --timeout=10s --start-period=30s --retries=3 \ + CMD python -c "import sys; sys.exit(0)" + +# 默认命令(可被覆盖) +CMD ["python", "main.py", "--schedule"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..3f8e14a03 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ZhuLinsen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..1751c0e79 --- /dev/null +++ b/README.md @@ -0,0 +1,256 @@ +# 📈 A股智能分析系统 + +[![GitHub stars](https://img.shields.io/github/stars/ZhuLinsen/daily_stock_analysis?style=social)](https://github.com/ZhuLinsen/daily_stock_analysis/stargazers) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![GitHub Actions](https://img.shields.io/badge/GitHub%20Actions-Ready-2088FF?logo=github-actions&logoColor=white)](https://github.com/features/actions) + +> 🤖 基于 AI 大模型的 A 股自选股智能分析系统,每日自动分析并推送「决策仪表盘」到企业微信/钉钉/飞书 + +![示例效果](./sources/sample.png) + +## ✨ 功能特性 + +### 🎯 核心功能 +- **AI 决策仪表盘** - 一句话核心结论 + 精确买卖点位 + 检查清单 +- **多维度分析** - 技术面 + 筹码分布 + 舆情情报 + 实时行情 +- **大盘复盘** - 每日市场概览、板块涨跌、北向资金 +- **定时推送** - 支持企业微信机器人自动推送 +- **零成本部署** - GitHub Actions 免费运行,无需服务器 +- **💰 白嫖 Gemini API** - Google AI Studio 提供免费额度,个人使用完全够用 + +### 📊 数据来源 +- **行情数据**: AkShare(免费)、Tushare、Baostock、YFinance +- **新闻搜索**: Tavily、SerpAPI +- **AI 分析**: Google Gemini(gemini-3-flash-preview)—— [免费获取 API Key](https://aistudio.google.com/) + +### 🛡️ 交易理念内置 +- ❌ **严禁追高** - 乖离率 > 5% 自动标记「危险」 +- ✅ **趋势交易** - MA5 > MA10 > MA20 多头排列 +- 📍 **精确点位** - 买入价、止损价、目标价 +- 📋 **检查清单** - 每项条件用 ✅⚠️❌ 标记 + +## 🚀 快速开始 + +### 方式一:GitHub Actions(推荐,零成本) + +**无需服务器,每天自动运行!** + +#### 1. Fork 本仓库 + +点击右上角 `Fork` 按钮 + +#### 2. 配置 Secrets + +进入你 Fork 的仓库 → `Settings` → `Secrets and variables` → `Actions` → `New repository secret` + +| Secret 名称 | 说明 | 必填 | +|------------|------|:----:| +| `GEMINI_API_KEY` | [Google AI Studio](https://aistudio.google.com/) 获取 | ✅ | +| `WECHAT_WEBHOOK_URL` | 企业微信机器人 Webhook | ✅ | +| `STOCK_LIST` | 自选股代码,如 `600519,300750,002594` | ✅ | +| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) 搜索 API(新闻搜索) | 推荐 | +| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/) Key | 可选 | +| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/) Token | 可选 | + +#### 3. 启用 Actions + +进入 `Actions` 标签 → 点击 `I understand my workflows, go ahead and enable them` + +#### 4. 手动测试 + +`Actions` → `每日股票分析` → `Run workflow` → 选择模式 → `Run workflow` + +#### 5. 完成! + +默认每个工作日 **18:00(北京时间)** 自动执行 + +### 方式二:本地运行 + +```bash +# 克隆仓库 +git clone https://github.com/ZhuLinsen/daily_stock_analysis.git +cd daily_stock_analysis + +# 安装依赖 +pip install -r requirements.txt + +# 配置环境变量 +cp .env.example .env +vim .env # 填入你的 API Key + +# 运行 +python main.py # 完整分析 +python main.py --market-review # 仅大盘复盘 +python main.py --schedule # 定时任务模式 +``` + +### 方式三:Docker 部署 + +```bash +# 配置环境变量 +cp .env.example .env +vim .env + +# 一键启动 +docker-compose up -d + +# 查看日志 +docker-compose logs -f +``` + +## 📱 推送效果 + +### 决策仪表盘 +``` +📊 2026-01-10 决策仪表盘 +3只股票 | 🟢买入:1 🟡观望:2 🔴卖出:0 + +🟢 买入 | 贵州茅台(600519) +📌 缩量回踩MA5支撑,乖离率1.2%处于最佳买点 +💰 狙击: 买入1800 | 止损1750 | 目标1900 +✅多头排列 ✅乖离安全 ✅量能配合 + +🟡 观望 | 宁德时代(300750) +📌 乖离率7.8%超过5%警戒线,严禁追高 +⚠️ 等待回调至MA5附近再考虑 + +--- +生成时间: 18:00 +``` + +### 大盘复盘 +``` +🎯 2026-01-10 大盘复盘 + +📊 主要指数 +- 上证指数: 3250.12 (🟢+0.85%) +- 深证成指: 10521.36 (🟢+1.02%) +- 创业板指: 2156.78 (🟢+1.35%) + +📈 市场概况 +上涨: 3920 | 下跌: 1349 | 涨停: 155 | 跌停: 3 + +🔥 板块表现 +领涨: 互联网服务、文化传媒、小金属 +领跌: 保险、航空机场、光伏设备 +``` + +## ⚙️ 配置说明 + +### 环境变量 + +```bash +# === 必填 === +GEMINI_API_KEY=your_gemini_key # Gemini AI +WECHAT_WEBHOOK_URL=https://qyapi... # 企业微信机器人 +STOCK_LIST=600519,300750,002594 # 自选股列表 + +# === 推荐 === +TAVILY_API_KEYS=your_tavily_key # 新闻搜索 +GEMINI_MODEL=gemini-3-flash-preview # 主模型 +GEMINI_MODEL_FALLBACK=gemini-2.5-flash # 备选模型 + +# === 可选 === +TUSHARE_TOKEN=your_token # Tushare数据源 +SERPAPI_API_KEYS=your_serpapi_key # 备用搜索 +``` + +### 定时配置(GitHub Actions) + +编辑 `.github/workflows/daily_analysis.yml`: + +```yaml +schedule: + # UTC 时间,北京时间 = UTC + 8 + - cron: '0 10 * * 1-5' # 周一到周五 18:00(北京时间) +``` + +| 北京时间 | UTC cron | +|---------|----------| +| 09:30 | `'30 1 * * 1-5'` | +| 15:00 | `'0 7 * * 1-5'` | +| 18:00 | `'0 10 * * 1-5'` | + +## 📁 项目结构 + +``` +daily_stock_analysis/ +├── main.py # 主程序入口 +├── analyzer.py # AI 分析器(Gemini) +├── market_analyzer.py # 大盘复盘分析 +├── search_service.py # 新闻搜索服务 +├── notification.py # 消息推送 +├── scheduler.py # 定时任务 +├── storage.py # 数据存储 +├── config.py # 配置管理 +├── data_provider/ # 数据源适配器 +│ ├── akshare_fetcher.py +│ ├── tushare_fetcher.py +│ ├── baostock_fetcher.py +│ └── yfinance_fetcher.py +├── .github/workflows/ # GitHub Actions +├── Dockerfile # Docker 镜像 +└── docker-compose.yml # Docker 编排 +``` + +## 🗺️ Roadmap + +> 📢 以下功能将视后续情况逐步完成,如果你有好的想法或建议,欢迎 [提交 Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues) 讨论! + +### 🔔 通知渠道扩展 +- [x] 企业微信机器人 +- [ ] 钉钉机器人 +- [ ] 飞书机器人 +- [ ] Telegram Bot +- [ ] Discord Webhook +- [ ] Slack Webhook +- [ ] 邮件通知 +- [ ] iOS/Android 推送(Bark/Pushover) + +### 🤖 AI 模型支持 +- [x] Google Gemini +- [ ] OpenAI GPT-4 +- [ ] Claude +- [ ] 通义千问 +- [ ] 文心一言 +- [ ] DeepSeek +- [ ] 本地模型(Ollama) + +### 📊 数据源扩展 +- [x] AkShare(免费) +- [x] Tushare Pro +- [x] Baostock +- [x] YFinance +- [ ] 东方财富 API +- [ ] 同花顺 API +- [ ] 新浪财经 + +### 🎯 功能增强 +- [x] 决策仪表盘 +- [x] 大盘复盘 +- [x] 定时推送 +- [x] GitHub Actions +- [ ] Web 管理界面 +- [ ] 自选股动态管理 API +- [ ] 历史分析回测 +- [ ] 多策略支持 +- [ ] 港股/美股支持 + +## 🤝 贡献 + +欢迎提交 Issue 和 Pull Request! + +详见 [贡献指南](CONTRIBUTING.md) + +## 📄 License + +[MIT License](LICENSE) © 2026 ZhuLinsen + +## ⚠️ 免责声明 + +本项目仅供学习和研究使用,不构成任何投资建议。股市有风险,投资需谨慎。作者不对使用本项目产生的任何损失负责。 + +--- + +**如果觉得有用,请给个 ⭐ Star 支持一下!** diff --git a/analyzer.py b/analyzer.py new file mode 100644 index 000000000..ec7e3d992 --- /dev/null +++ b/analyzer.py @@ -0,0 +1,1074 @@ +# -*- coding: utf-8 -*- +""" +=================================== +A股自选股智能分析系统 - AI分析层 +=================================== + +职责: +1. 封装 Gemini API 调用逻辑 +2. 利用 Google Search Grounding 获取实时新闻 +3. 结合技术面和消息面生成分析报告 +""" + +import json +import logging +import time +from dataclasses import dataclass +from typing import Optional, Dict, Any, List + +from tenacity import ( + retry, + stop_after_attempt, + wait_exponential, + retry_if_exception_type, + before_sleep_log, +) + +from config import get_config + +logger = logging.getLogger(__name__) + + +# 股票名称映射(常见股票) +STOCK_NAME_MAP = { + '600519': '贵州茅台', + '000001': '平安银行', + '300750': '宁德时代', + '002594': '比亚迪', + '600036': '招商银行', + '601318': '中国平安', + '000858': '五粮液', + '600276': '恒瑞医药', + '601012': '隆基绿能', + '002475': '立讯精密', + '300059': '东方财富', + '002415': '海康威视', + '600900': '长江电力', + '601166': '兴业银行', + '600028': '中国石化', +} + + +@dataclass +class AnalysisResult: + """ + AI 分析结果数据类 - 决策仪表盘版 + + 封装 Gemini 返回的分析结果,包含决策仪表盘和详细分析 + """ + code: str + name: str + + # ========== 核心指标 ========== + sentiment_score: int # 综合评分 0-100 (>70强烈看多, >60看多, 40-60震荡, <40看空) + trend_prediction: str # 趋势预测:强烈看多/看多/震荡/看空/强烈看空 + operation_advice: str # 操作建议:买入/加仓/持有/减仓/卖出/观望 + confidence_level: str = "中" # 置信度:高/中/低 + + # ========== 决策仪表盘 (新增) ========== + dashboard: Optional[Dict[str, Any]] = None # 完整的决策仪表盘数据 + + # ========== 走势分析 ========== + trend_analysis: str = "" # 走势形态分析(支撑位、压力位、趋势线等) + short_term_outlook: str = "" # 短期展望(1-3日) + medium_term_outlook: str = "" # 中期展望(1-2周) + + # ========== 技术面分析 ========== + technical_analysis: str = "" # 技术指标综合分析 + ma_analysis: str = "" # 均线分析(多头/空头排列,金叉/死叉等) + volume_analysis: str = "" # 量能分析(放量/缩量,主力动向等) + pattern_analysis: str = "" # K线形态分析 + + # ========== 基本面分析 ========== + fundamental_analysis: str = "" # 基本面综合分析 + sector_position: str = "" # 板块地位和行业趋势 + company_highlights: str = "" # 公司亮点/风险点 + + # ========== 情绪面/消息面分析 ========== + news_summary: str = "" # 近期重要新闻/公告摘要 + market_sentiment: str = "" # 市场情绪分析 + hot_topics: str = "" # 相关热点话题 + + # ========== 综合分析 ========== + analysis_summary: str = "" # 综合分析摘要 + key_points: str = "" # 核心看点(3-5个要点) + risk_warning: str = "" # 风险提示 + buy_reason: str = "" # 买入/卖出理由 + + # ========== 元数据 ========== + raw_response: Optional[str] = None # 原始响应(调试用) + search_performed: bool = False # 是否执行了联网搜索 + data_sources: str = "" # 数据来源说明 + success: bool = True + error_message: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + 'code': self.code, + 'name': self.name, + 'sentiment_score': self.sentiment_score, + 'trend_prediction': self.trend_prediction, + 'operation_advice': self.operation_advice, + 'confidence_level': self.confidence_level, + 'dashboard': self.dashboard, # 决策仪表盘数据 + 'trend_analysis': self.trend_analysis, + 'short_term_outlook': self.short_term_outlook, + 'medium_term_outlook': self.medium_term_outlook, + 'technical_analysis': self.technical_analysis, + 'ma_analysis': self.ma_analysis, + 'volume_analysis': self.volume_analysis, + 'pattern_analysis': self.pattern_analysis, + 'fundamental_analysis': self.fundamental_analysis, + 'sector_position': self.sector_position, + 'company_highlights': self.company_highlights, + 'news_summary': self.news_summary, + 'market_sentiment': self.market_sentiment, + 'hot_topics': self.hot_topics, + 'analysis_summary': self.analysis_summary, + 'key_points': self.key_points, + 'risk_warning': self.risk_warning, + 'buy_reason': self.buy_reason, + 'search_performed': self.search_performed, + 'success': self.success, + 'error_message': self.error_message, + } + + def get_core_conclusion(self) -> str: + """获取核心结论(一句话)""" + if self.dashboard and 'core_conclusion' in self.dashboard: + return self.dashboard['core_conclusion'].get('one_sentence', self.analysis_summary) + return self.analysis_summary + + def get_position_advice(self, has_position: bool = False) -> str: + """获取持仓建议""" + if self.dashboard and 'core_conclusion' in self.dashboard: + pos_advice = self.dashboard['core_conclusion'].get('position_advice', {}) + if has_position: + return pos_advice.get('has_position', self.operation_advice) + return pos_advice.get('no_position', self.operation_advice) + return self.operation_advice + + def get_sniper_points(self) -> Dict[str, str]: + """获取狙击点位""" + if self.dashboard and 'battle_plan' in self.dashboard: + return self.dashboard['battle_plan'].get('sniper_points', {}) + return {} + + def get_checklist(self) -> List[str]: + """获取检查清单""" + if self.dashboard and 'battle_plan' in self.dashboard: + return self.dashboard['battle_plan'].get('action_checklist', []) + return [] + + def get_risk_alerts(self) -> List[str]: + """获取风险警报""" + if self.dashboard and 'intelligence' in self.dashboard: + return self.dashboard['intelligence'].get('risk_alerts', []) + return [] + + def get_emoji(self) -> str: + """根据操作建议返回对应 emoji""" + emoji_map = { + '买入': '🟢', + '加仓': '🟢', + '强烈买入': '💚', + '持有': '🟡', + '观望': '⚪', + '减仓': '🟠', + '卖出': '🔴', + '强烈卖出': '❌', + } + return emoji_map.get(self.operation_advice, '🟡') + + def get_confidence_stars(self) -> str: + """返回置信度星级""" + star_map = {'高': '⭐⭐⭐', '中': '⭐⭐', '低': '⭐'} + return star_map.get(self.confidence_level, '⭐⭐') + + +class GeminiAnalyzer: + """ + Gemini AI 分析器 + + 职责: + 1. 调用 Google Gemini API 进行股票分析 + 2. 结合预先搜索的新闻和技术面数据生成分析报告 + 3. 解析 AI 返回的 JSON 格式结果 + + 使用方式: + analyzer = GeminiAnalyzer() + result = analyzer.analyze(context, news_context) + """ + + # ======================================== + # 系统提示词 - 决策仪表盘 v2.0 + # ======================================== + # 输出格式升级:从简单信号升级为决策仪表盘 + # 核心模块:核心结论 + 数据透视 + 舆情情报 + 作战计划 + # ======================================== + + SYSTEM_PROMPT = """你是一位专注于趋势交易的 A 股投资分析师,负责生成专业的【决策仪表盘】分析报告。 + +## 核心交易理念(必须严格遵守) + +### 1. 严进策略(不追高) +- **绝对不追高**:当股价偏离 MA5 超过 5% 时,坚决不买入 +- **乖离率公式**:(现价 - MA5) / MA5 × 100% +- 乖离率 < 2%:最佳买点区间 +- 乖离率 2-5%:可小仓介入 +- 乖离率 > 5%:严禁追高!直接判定为"观望" + +### 2. 趋势交易(顺势而为) +- **多头排列必须条件**:MA5 > MA10 > MA20 +- 只做多头排列的股票,空头排列坚决不碰 +- 均线发散上行优于均线粘合 +- 趋势强度判断:看均线间距是否在扩大 + +### 3. 效率优先(筹码结构) +- 关注筹码集中度:90%集中度 < 15% 表示筹码集中 +- 获利比例分析:70-90% 获利盘时需警惕获利回吐 +- 平均成本与现价关系:现价高于平均成本 5-15% 为健康 + +### 4. 买点偏好(回踩支撑) +- **最佳买点**:缩量回踩 MA5 获得支撑 +- **次优买点**:回踩 MA10 获得支撑 +- **观望情况**:跌破 MA20 时观望 + +### 5. 风险排查重点 +- 减持公告(股东、高管减持) +- 业绩预亏/大幅下滑 +- 监管处罚/立案调查 +- 行业政策利空 +- 大额解禁 + +## 输出格式:决策仪表盘 JSON + +请严格按照以下 JSON 格式输出,这是一个完整的【决策仪表盘】: + +```json +{ + "sentiment_score": 0-100整数, + "trend_prediction": "强烈看多/看多/震荡/看空/强烈看空", + "operation_advice": "买入/加仓/持有/减仓/卖出/观望", + "confidence_level": "高/中/低", + + "dashboard": { + "core_conclusion": { + "one_sentence": "一句话核心结论(30字以内,直接告诉用户做什么)", + "signal_type": "🟢买入信号/🟡持有观望/🔴卖出信号/⚠️风险警告", + "time_sensitivity": "立即行动/今日内/本周内/不急", + "position_advice": { + "no_position": "空仓者建议:具体操作指引", + "has_position": "持仓者建议:具体操作指引" + } + }, + + "data_perspective": { + "trend_status": { + "ma_alignment": "均线排列状态描述", + "is_bullish": true/false, + "trend_score": 0-100 + }, + "price_position": { + "current_price": 当前价格数值, + "ma5": MA5数值, + "ma10": MA10数值, + "ma20": MA20数值, + "bias_ma5": 乖离率百分比数值, + "bias_status": "安全/警戒/危险", + "support_level": 支撑位价格, + "resistance_level": 压力位价格 + }, + "volume_analysis": { + "volume_ratio": 量比数值, + "volume_status": "放量/缩量/平量", + "turnover_rate": 换手率百分比, + "volume_meaning": "量能含义解读(如:缩量回调表示抛压减轻)" + }, + "chip_structure": { + "profit_ratio": 获利比例, + "avg_cost": 平均成本, + "concentration": 筹码集中度, + "chip_health": "健康/一般/警惕" + } + }, + + "intelligence": { + "latest_news": "【最新消息】近期重要新闻摘要", + "risk_alerts": ["风险点1:具体描述", "风险点2:具体描述"], + "positive_catalysts": ["利好1:具体描述", "利好2:具体描述"], + "earnings_outlook": "业绩预期分析(基于年报预告、业绩快报等)", + "sentiment_summary": "舆情情绪一句话总结" + }, + + "battle_plan": { + "sniper_points": { + "ideal_buy": "理想买入点:XX元(在MA5附近)", + "secondary_buy": "次优买入点:XX元(在MA10附近)", + "stop_loss": "止损位:XX元(跌破MA20或X%)", + "take_profit": "目标位:XX元(前高/整数关口)" + }, + "position_strategy": { + "suggested_position": "建议仓位:X成", + "entry_plan": "分批建仓策略描述", + "risk_control": "风控策略描述" + }, + "action_checklist": [ + "✅/⚠️/❌ 检查项1:多头排列", + "✅/⚠️/❌ 检查项2:乖离率<5%", + "✅/⚠️/❌ 检查项3:量能配合", + "✅/⚠️/❌ 检查项4:无重大利空", + "✅/⚠️/❌ 检查项5:筹码健康" + ] + } + }, + + "analysis_summary": "100字综合分析摘要", + "key_points": "3-5个核心看点,逗号分隔", + "risk_warning": "风险提示", + "buy_reason": "操作理由,引用交易理念", + + "trend_analysis": "走势形态分析", + "short_term_outlook": "短期1-3日展望", + "medium_term_outlook": "中期1-2周展望", + "technical_analysis": "技术面综合分析", + "ma_analysis": "均线系统分析", + "volume_analysis": "量能分析", + "pattern_analysis": "K线形态分析", + "fundamental_analysis": "基本面分析", + "sector_position": "板块行业分析", + "company_highlights": "公司亮点/风险", + "news_summary": "新闻摘要", + "market_sentiment": "市场情绪", + "hot_topics": "相关热点", + + "search_performed": true/false, + "data_sources": "数据来源说明" +} +``` + +## 评分标准 + +### 强烈买入(80-100分): +- ✅ 多头排列:MA5 > MA10 > MA20 +- ✅ 低乖离率:<2%,最佳买点 +- ✅ 缩量回调或放量突破 +- ✅ 筹码集中健康 +- ✅ 消息面有利好催化 + +### 买入(60-79分): +- ✅ 多头排列或弱势多头 +- ✅ 乖离率 <5% +- ✅ 量能正常 +- ⚪ 允许一项次要条件不满足 + +### 观望(40-59分): +- ⚠️ 乖离率 >5%(追高风险) +- ⚠️ 均线缠绕趋势不明 +- ⚠️ 有风险事件 + +### 卖出/减仓(0-39分): +- ❌ 空头排列 +- ❌ 跌破MA20 +- ❌ 放量下跌 +- ❌ 重大利空 + +## 决策仪表盘核心原则 + +1. **核心结论先行**:一句话说清该买该卖 +2. **分持仓建议**:空仓者和持仓者给不同建议 +3. **精确狙击点**:必须给出具体价格,不说模糊的话 +4. **检查清单可视化**:用 ✅⚠️❌ 明确显示每项检查结果 +5. **风险优先级**:舆情中的风险点要醒目标出""" + + def __init__(self, api_key: Optional[str] = None): + """ + 初始化 Gemini 分析器 + + Args: + api_key: Gemini API Key(可选,默认从配置读取) + """ + self._api_key = api_key or get_config().gemini_api_key + self._model = None + self._current_model_name = None # 当前使用的模型名称 + self._using_fallback = False # 是否正在使用备选模型 + + if self._api_key: + self._init_model() + else: + logger.warning("Gemini API Key 未配置,AI 分析功能将不可用") + + def _init_model(self) -> None: + """ + 初始化 Gemini 模型 + + 配置: + - 使用 gemini-3-flash-preview 或 gemini-2.5-flash 模型 + - 不启用 Google Search(使用外部 Tavily/SerpAPI 搜索) + """ + try: + import google.generativeai as genai + + # 配置 API Key + genai.configure(api_key=self._api_key) + + # 从配置获取模型名称 + config = get_config() + model_name = config.gemini_model + fallback_model = config.gemini_model_fallback + + # 不再使用 Google Search Grounding(已知有兼容性问题) + # 改为使用外部搜索服务(Tavily/SerpAPI)预先获取新闻 + + # 尝试初始化主模型 + try: + self._model = genai.GenerativeModel( + model_name=model_name, + system_instruction=self.SYSTEM_PROMPT, + ) + self._current_model_name = model_name + self._using_fallback = False + logger.info(f"Gemini 模型初始化成功 (模型: {model_name})") + except Exception as model_error: + # 尝试备选模型 + logger.warning(f"主模型 {model_name} 初始化失败: {model_error},尝试备选模型 {fallback_model}") + self._model = genai.GenerativeModel( + model_name=fallback_model, + system_instruction=self.SYSTEM_PROMPT, + ) + self._current_model_name = fallback_model + self._using_fallback = True + logger.info(f"Gemini 备选模型初始化成功 (模型: {fallback_model})") + + except Exception as e: + logger.error(f"Gemini 模型初始化失败: {e}") + self._model = None + + def _switch_to_fallback_model(self) -> bool: + """ + 切换到备选模型 + + Returns: + 是否成功切换 + """ + try: + import google.generativeai as genai + config = get_config() + fallback_model = config.gemini_model_fallback + + logger.warning(f"[LLM] 切换到备选模型: {fallback_model}") + self._model = genai.GenerativeModel( + model_name=fallback_model, + system_instruction=self.SYSTEM_PROMPT, + ) + self._current_model_name = fallback_model + self._using_fallback = True + logger.info(f"[LLM] 备选模型 {fallback_model} 初始化成功") + return True + except Exception as e: + logger.error(f"[LLM] 切换备选模型失败: {e}") + return False + + def is_available(self) -> bool: + """检查分析器是否可用""" + return self._model is not None + + def _call_api_with_retry(self, prompt: str, generation_config: dict) -> str: + """ + 调用 Gemini API,带有重试和模型切换机制 + + 处理 429 限流错误: + 1. 先指数退避重试 + 2. 多次失败后切换到备选模型 + 3. 备选模型也失败则抛出异常 + + Args: + prompt: 提示词 + generation_config: 生成配置 + + Returns: + 响应文本 + """ + config = get_config() + max_retries = config.gemini_max_retries + base_delay = config.gemini_retry_delay + + last_error = None + tried_fallback = getattr(self, '_using_fallback', False) + + for attempt in range(max_retries): + try: + # 请求前增加延时(防止请求过快触发限流) + if attempt > 0: + delay = base_delay * (2 ** (attempt - 1)) # 指数退避: 5, 10, 20, 40... + delay = min(delay, 60) # 最大60秒 + logger.info(f"[LLM] 第 {attempt + 1} 次重试,等待 {delay:.1f} 秒...") + time.sleep(delay) + + response = self._model.generate_content( + prompt, + generation_config=generation_config, + request_options={"timeout": 120} + ) + + if response and response.text: + return response.text + else: + raise ValueError("Gemini 返回空响应") + + except Exception as e: + last_error = e + error_str = str(e) + + # 检查是否是 429 限流错误 + is_rate_limit = '429' in error_str or 'quota' in error_str.lower() or 'rate' in error_str.lower() + + if is_rate_limit: + logger.warning(f"[LLM] API 限流 (429),第 {attempt + 1}/{max_retries} 次尝试: {error_str[:100]}") + + # 如果已经重试了一半次数且还没切换过备选模型,尝试切换 + if attempt >= max_retries // 2 and not tried_fallback: + if self._switch_to_fallback_model(): + tried_fallback = True + logger.info("[LLM] 已切换到备选模型,继续重试") + else: + logger.warning("[LLM] 切换备选模型失败,继续使用当前模型重试") + else: + # 非限流错误,记录并继续重试 + logger.warning(f"[LLM] API 调用失败,第 {attempt + 1}/{max_retries} 次尝试: {error_str[:100]}") + + # 所有重试都失败 + raise last_error or Exception("Gemini API 调用失败,已达最大重试次数") + + def analyze( + self, + context: Dict[str, Any], + news_context: Optional[str] = None + ) -> AnalysisResult: + """ + 分析单只股票 + + 流程: + 1. 格式化输入数据(技术面 + 新闻) + 2. 调用 Gemini API(带重试和模型切换) + 3. 解析 JSON 响应 + 4. 返回结构化结果 + + Args: + context: 从 storage.get_analysis_context() 获取的上下文数据 + news_context: 预先搜索的新闻内容(可选) + + Returns: + AnalysisResult 对象 + """ + code = context.get('code', 'Unknown') + config = get_config() + + # 请求前增加延时(防止连续请求触发限流) + request_delay = config.gemini_request_delay + if request_delay > 0: + logger.debug(f"[LLM] 请求前等待 {request_delay:.1f} 秒...") + time.sleep(request_delay) + + # 优先从上下文获取股票名称(由 main.py 传入) + name = context.get('stock_name') + if not name or name.startswith('股票'): + # 备选:从 realtime 中获取 + if 'realtime' in context and context['realtime'].get('name'): + name = context['realtime']['name'] + else: + # 最后从映射表获取 + name = STOCK_NAME_MAP.get(code, f'股票{code}') + + # 如果模型不可用,返回默认结果 + if not self.is_available(): + return AnalysisResult( + code=code, + name=name, + sentiment_score=50, + trend_prediction='震荡', + operation_advice='持有', + confidence_level='低', + analysis_summary='AI 分析功能未启用(未配置 API Key)', + risk_warning='请配置 Gemini API Key 后重试', + success=False, + error_message='Gemini API Key 未配置', + ) + + try: + # 格式化输入(包含技术面数据和新闻) + prompt = self._format_prompt(context, name, news_context) + + # 获取模型名称 + model_name = getattr(self, '_current_model_name', None) + if not model_name: + model_name = getattr(self._model, '_model_name', 'unknown') + if hasattr(self._model, 'model_name'): + model_name = self._model.model_name + + logger.info(f"========== AI 分析 {name}({code}) ==========") + logger.info(f"[LLM配置] 模型: {model_name}") + logger.info(f"[LLM配置] Prompt 长度: {len(prompt)} 字符") + logger.info(f"[LLM配置] 是否包含新闻: {'是' if news_context else '否'}") + + # 记录完整 prompt 到日志(INFO级别记录摘要,DEBUG记录完整) + prompt_preview = prompt[:500] + "..." if len(prompt) > 500 else prompt + logger.info(f"[LLM Prompt 预览]\n{prompt_preview}") + logger.debug(f"=== 完整 Prompt ({len(prompt)}字符) ===\n{prompt}\n=== End Prompt ===") + + # 设置生成配置 + generation_config = { + "temperature": 0.7, + "max_output_tokens": 8192, + } + + logger.info(f"[LLM调用] 开始调用 Gemini API (temperature={generation_config['temperature']}, max_tokens={generation_config['max_output_tokens']})...") + + # 使用带重试的 API 调用 + start_time = time.time() + response_text = self._call_api_with_retry(prompt, generation_config) + elapsed = time.time() - start_time + + # 记录响应信息 + logger.info(f"[LLM返回] Gemini API 响应成功, 耗时 {elapsed:.2f}s, 响应长度 {len(response_text)} 字符") + + # 记录响应预览(INFO级别)和完整响应(DEBUG级别) + response_preview = response_text[:300] + "..." if len(response_text) > 300 else response_text + logger.info(f"[LLM返回 预览]\n{response_preview}") + logger.debug(f"=== Gemini 完整响应 ({len(response_text)}字符) ===\n{response_text}\n=== End Response ===") + + # 解析响应 + result = self._parse_response(response_text, code, name) + result.raw_response = response_text + result.search_performed = bool(news_context) + + logger.info(f"[LLM解析] {name}({code}) 分析完成: {result.trend_prediction}, 评分 {result.sentiment_score}") + + return result + + except Exception as e: + logger.error(f"AI 分析 {name}({code}) 失败: {e}") + return AnalysisResult( + code=code, + name=name, + sentiment_score=50, + trend_prediction='震荡', + operation_advice='持有', + confidence_level='低', + analysis_summary=f'分析过程出错: {str(e)[:100]}', + risk_warning='分析失败,请稍后重试或手动分析', + success=False, + error_message=str(e), + ) + + def _format_prompt( + self, + context: Dict[str, Any], + name: str, + news_context: Optional[str] = None + ) -> str: + """ + 格式化分析提示词(决策仪表盘 v2.0) + + 包含:技术指标、实时行情(量比/换手率)、筹码分布、趋势分析、新闻 + + Args: + context: 技术面数据上下文(包含增强数据) + name: 股票名称(默认值,可能被上下文覆盖) + news_context: 预先搜索的新闻内容 + """ + code = context.get('code', 'Unknown') + + # 优先使用上下文中的股票名称(从 realtime_quote 获取) + stock_name = context.get('stock_name', name) + if not stock_name or stock_name == f'股票{code}': + stock_name = STOCK_NAME_MAP.get(code, f'股票{code}') + + today = context.get('today', {}) + + # ========== 构建决策仪表盘格式的输入 ========== + prompt = f"""# 决策仪表盘分析请求 + +## 📊 股票基础信息 +| 项目 | 数据 | +|------|------| +| 股票代码 | **{code}** | +| 股票名称 | **{stock_name}** | +| 分析日期 | {context.get('date', '未知')} | + +--- + +## 📈 技术面数据 + +### 今日行情 +| 指标 | 数值 | +|------|------| +| 收盘价 | {today.get('close', 'N/A')} 元 | +| 开盘价 | {today.get('open', 'N/A')} 元 | +| 最高价 | {today.get('high', 'N/A')} 元 | +| 最低价 | {today.get('low', 'N/A')} 元 | +| 涨跌幅 | {today.get('pct_chg', 'N/A')}% | +| 成交量 | {self._format_volume(today.get('volume'))} | +| 成交额 | {self._format_amount(today.get('amount'))} | + +### 均线系统(关键判断指标) +| 均线 | 数值 | 说明 | +|------|------|------| +| MA5 | {today.get('ma5', 'N/A')} | 短期趋势线 | +| MA10 | {today.get('ma10', 'N/A')} | 中短期趋势线 | +| MA20 | {today.get('ma20', 'N/A')} | 中期趋势线 | +| 均线形态 | {context.get('ma_status', '未知')} | 多头/空头/缠绕 | +""" + + # 添加实时行情数据(量比、换手率等) + if 'realtime' in context: + rt = context['realtime'] + prompt += f""" +### 实时行情增强数据 +| 指标 | 数值 | 解读 | +|------|------|------| +| 当前价格 | {rt.get('price', 'N/A')} 元 | | +| **量比** | **{rt.get('volume_ratio', 'N/A')}** | {rt.get('volume_ratio_desc', '')} | +| **换手率** | **{rt.get('turnover_rate', 'N/A')}%** | | +| 市盈率(动态) | {rt.get('pe_ratio', 'N/A')} | | +| 市净率 | {rt.get('pb_ratio', 'N/A')} | | +| 总市值 | {self._format_amount(rt.get('total_mv'))} | | +| 流通市值 | {self._format_amount(rt.get('circ_mv'))} | | +| 60日涨跌幅 | {rt.get('change_60d', 'N/A')}% | 中期表现 | +""" + + # 添加筹码分布数据 + if 'chip' in context: + chip = context['chip'] + profit_ratio = chip.get('profit_ratio', 0) + prompt += f""" +### 筹码分布数据(效率指标) +| 指标 | 数值 | 健康标准 | +|------|------|----------| +| **获利比例** | **{profit_ratio:.1%}** | 70-90%时警惕 | +| 平均成本 | {chip.get('avg_cost', 'N/A')} 元 | 现价应高于5-15% | +| 90%筹码集中度 | {chip.get('concentration_90', 0):.2%} | <15%为集中 | +| 70%筹码集中度 | {chip.get('concentration_70', 0):.2%} | | +| 筹码状态 | {chip.get('chip_status', '未知')} | | +""" + + # 添加趋势分析结果(基于交易理念的预判) + if 'trend_analysis' in context: + trend = context['trend_analysis'] + bias_warning = "🚨 超过5%,严禁追高!" if trend.get('bias_ma5', 0) > 5 else "✅ 安全范围" + prompt += f""" +### 趋势分析预判(基于交易理念) +| 指标 | 数值 | 判定 | +|------|------|------| +| 趋势状态 | {trend.get('trend_status', '未知')} | | +| 均线排列 | {trend.get('ma_alignment', '未知')} | MA5>MA10>MA20为多头 | +| 趋势强度 | {trend.get('trend_strength', 0)}/100 | | +| **乖离率(MA5)** | **{trend.get('bias_ma5', 0):+.2f}%** | {bias_warning} | +| 乖离率(MA10) | {trend.get('bias_ma10', 0):+.2f}% | | +| 量能状态 | {trend.get('volume_status', '未知')} | {trend.get('volume_trend', '')} | +| 系统信号 | {trend.get('buy_signal', '未知')} | | +| 系统评分 | {trend.get('signal_score', 0)}/100 | | + +#### 系统分析理由 +**买入理由**: +{chr(10).join('- ' + r for r in trend.get('signal_reasons', ['无'])) if trend.get('signal_reasons') else '- 无'} + +**风险因素**: +{chr(10).join('- ' + r for r in trend.get('risk_factors', ['无'])) if trend.get('risk_factors') else '- 无'} +""" + + # 添加昨日对比数据 + if 'yesterday' in context: + volume_change = context.get('volume_change_ratio', 'N/A') + prompt += f""" +### 量价变化 +- 成交量较昨日变化:{volume_change}倍 +- 价格较昨日变化:{context.get('price_change_ratio', 'N/A')}% +""" + + # 添加新闻搜索结果(重点区域) + prompt += """ +--- + +## 📰 舆情情报 +""" + if news_context: + prompt += f""" +以下是 **{stock_name}({code})** 近7日的新闻搜索结果,请重点提取: +1. 🚨 **风险警报**:减持、处罚、利空 +2. 🎯 **利好催化**:业绩、合同、政策 +3. 📊 **业绩预期**:年报预告、业绩快报 + +``` +{news_context} +``` +""" + else: + prompt += """ +未搜索到该股票近期的相关新闻。请主要依据技术面数据进行分析。 +""" + + # 明确的输出要求 + prompt += f""" +--- + +## ✅ 分析任务 + +请为 **{stock_name}({code})** 生成【决策仪表盘】,严格按照 JSON 格式输出。 + +### 重点关注(必须明确回答): +1. ❓ 是否满足 MA5>MA10>MA20 多头排列? +2. ❓ 当前乖离率是否在安全范围内(<5%)?—— 超过5%必须标注"严禁追高" +3. ❓ 量能是否配合(缩量回调/放量突破)? +4. ❓ 筹码结构是否健康? +5. ❓ 消息面有无重大利空?(减持、处罚、业绩变脸等) + +### 决策仪表盘要求: +- **核心结论**:一句话说清该买/该卖/该等 +- **持仓分类建议**:空仓者怎么做 vs 持仓者怎么做 +- **具体狙击点位**:买入价、止损价、目标价(精确到分) +- **检查清单**:每项用 ✅/⚠️/❌ 标记 + +请输出完整的 JSON 格式决策仪表盘。""" + + return prompt + + def _format_volume(self, volume: Optional[float]) -> str: + """格式化成交量显示""" + if volume is None: + return 'N/A' + if volume >= 1e8: + return f"{volume / 1e8:.2f} 亿股" + elif volume >= 1e4: + return f"{volume / 1e4:.2f} 万股" + else: + return f"{volume:.0f} 股" + + def _format_amount(self, amount: Optional[float]) -> str: + """格式化成交额显示""" + if amount is None: + return 'N/A' + if amount >= 1e8: + return f"{amount / 1e8:.2f} 亿元" + elif amount >= 1e4: + return f"{amount / 1e4:.2f} 万元" + else: + return f"{amount:.0f} 元" + + def _parse_response( + self, + response_text: str, + code: str, + name: str + ) -> AnalysisResult: + """ + 解析 Gemini 响应(决策仪表盘版) + + 尝试从响应中提取 JSON 格式的分析结果,包含 dashboard 字段 + 如果解析失败,尝试智能提取或返回默认结果 + """ + try: + # 清理响应文本:移除 markdown 代码块标记 + cleaned_text = response_text + if '```json' in cleaned_text: + cleaned_text = cleaned_text.replace('```json', '').replace('```', '') + elif '```' in cleaned_text: + cleaned_text = cleaned_text.replace('```', '') + + # 尝试找到 JSON 内容 + json_start = cleaned_text.find('{') + json_end = cleaned_text.rfind('}') + 1 + + if json_start >= 0 and json_end > json_start: + json_str = cleaned_text[json_start:json_end] + + # 尝试修复常见的 JSON 问题 + json_str = self._fix_json_string(json_str) + + data = json.loads(json_str) + + # 提取 dashboard 数据 + dashboard = data.get('dashboard', None) + + # 解析所有字段,使用默认值防止缺失 + return AnalysisResult( + code=code, + name=name, + # 核心指标 + sentiment_score=int(data.get('sentiment_score', 50)), + trend_prediction=data.get('trend_prediction', '震荡'), + operation_advice=data.get('operation_advice', '持有'), + confidence_level=data.get('confidence_level', '中'), + # 决策仪表盘 + dashboard=dashboard, + # 走势分析 + trend_analysis=data.get('trend_analysis', ''), + short_term_outlook=data.get('short_term_outlook', ''), + medium_term_outlook=data.get('medium_term_outlook', ''), + # 技术面 + technical_analysis=data.get('technical_analysis', ''), + ma_analysis=data.get('ma_analysis', ''), + volume_analysis=data.get('volume_analysis', ''), + pattern_analysis=data.get('pattern_analysis', ''), + # 基本面 + fundamental_analysis=data.get('fundamental_analysis', ''), + sector_position=data.get('sector_position', ''), + company_highlights=data.get('company_highlights', ''), + # 情绪面/消息面 + news_summary=data.get('news_summary', ''), + market_sentiment=data.get('market_sentiment', ''), + hot_topics=data.get('hot_topics', ''), + # 综合 + analysis_summary=data.get('analysis_summary', '分析完成'), + key_points=data.get('key_points', ''), + risk_warning=data.get('risk_warning', ''), + buy_reason=data.get('buy_reason', ''), + # 元数据 + search_performed=data.get('search_performed', False), + data_sources=data.get('data_sources', '技术面数据'), + success=True, + ) + else: + # 没有找到 JSON,尝试从纯文本中提取信息 + logger.warning(f"无法从响应中提取 JSON,使用原始文本分析") + return self._parse_text_response(response_text, code, name) + + except json.JSONDecodeError as e: + logger.warning(f"JSON 解析失败: {e},尝试从文本提取") + return self._parse_text_response(response_text, code, name) + + def _fix_json_string(self, json_str: str) -> str: + """修复常见的 JSON 格式问题""" + import re + + # 移除注释 + json_str = re.sub(r'//.*?\n', '\n', json_str) + json_str = re.sub(r'/\*.*?\*/', '', json_str, flags=re.DOTALL) + + # 修复尾随逗号 + json_str = re.sub(r',\s*}', '}', json_str) + json_str = re.sub(r',\s*]', ']', json_str) + + # 确保布尔值是小写 + json_str = json_str.replace('True', 'true').replace('False', 'false') + + return json_str + + def _parse_text_response( + self, + response_text: str, + code: str, + name: str + ) -> AnalysisResult: + """从纯文本响应中尽可能提取分析信息""" + # 尝试识别关键词来判断情绪 + sentiment_score = 50 + trend = '震荡' + advice = '持有' + + text_lower = response_text.lower() + + # 简单的情绪识别 + positive_keywords = ['看多', '买入', '上涨', '突破', '强势', '利好', '加仓', 'bullish', 'buy'] + negative_keywords = ['看空', '卖出', '下跌', '跌破', '弱势', '利空', '减仓', 'bearish', 'sell'] + + positive_count = sum(1 for kw in positive_keywords if kw in text_lower) + negative_count = sum(1 for kw in negative_keywords if kw in text_lower) + + if positive_count > negative_count + 1: + sentiment_score = 65 + trend = '看多' + advice = '买入' + elif negative_count > positive_count + 1: + sentiment_score = 35 + trend = '看空' + advice = '卖出' + + # 截取前500字符作为摘要 + summary = response_text[:500] if response_text else '无分析结果' + + return AnalysisResult( + code=code, + name=name, + sentiment_score=sentiment_score, + trend_prediction=trend, + operation_advice=advice, + confidence_level='低', + analysis_summary=summary, + key_points='JSON解析失败,仅供参考', + risk_warning='分析结果可能不准确,建议结合其他信息判断', + raw_response=response_text, + success=True, + ) + + def batch_analyze( + self, + contexts: List[Dict[str, Any]], + delay_between: float = 2.0 + ) -> List[AnalysisResult]: + """ + 批量分析多只股票 + + 注意:为避免 API 速率限制,每次分析之间会有延迟 + + Args: + contexts: 上下文数据列表 + delay_between: 每次分析之间的延迟(秒) + + Returns: + AnalysisResult 列表 + """ + results = [] + + for i, context in enumerate(contexts): + if i > 0: + logger.debug(f"等待 {delay_between} 秒后继续...") + time.sleep(delay_between) + + result = self.analyze(context) + results.append(result) + + return results + + +# 便捷函数 +def get_analyzer() -> GeminiAnalyzer: + """获取 Gemini 分析器实例""" + return GeminiAnalyzer() + + +if __name__ == "__main__": + # 测试代码 + logging.basicConfig(level=logging.DEBUG) + + # 模拟上下文数据 + test_context = { + 'code': '600519', + 'date': '2026-01-09', + 'today': { + 'open': 1800.0, + 'high': 1850.0, + 'low': 1780.0, + 'close': 1820.0, + 'volume': 10000000, + 'amount': 18200000000, + 'pct_chg': 1.5, + 'ma5': 1810.0, + 'ma10': 1800.0, + 'ma20': 1790.0, + 'volume_ratio': 1.2, + }, + 'ma_status': '多头排列 📈', + 'volume_change_ratio': 1.3, + 'price_change_ratio': 1.5, + } + + analyzer = GeminiAnalyzer() + + if analyzer.is_available(): + print("=== AI 分析测试 ===") + result = analyzer.analyze(test_context) + print(f"分析结果: {result.to_dict()}") + else: + print("Gemini API 未配置,跳过测试") diff --git a/config.py b/config.py new file mode 100644 index 000000000..fb43e0c08 --- /dev/null +++ b/config.py @@ -0,0 +1,217 @@ +# -*- coding: utf-8 -*- +""" +=================================== +A股自选股智能分析系统 - 配置管理模块 +=================================== + +职责: +1. 使用单例模式管理全局配置 +2. 从 .env 文件加载敏感配置 +3. 提供类型安全的配置访问接口 +""" + +import os +from pathlib import Path +from typing import List, Optional +from dotenv import load_dotenv +from dataclasses import dataclass, field + + +@dataclass +class Config: + """ + 系统配置类 - 单例模式 + + 设计说明: + - 使用 dataclass 简化配置属性定义 + - 所有配置项从环境变量读取,支持默认值 + - 类方法 get_instance() 实现单例访问 + """ + + # === 自选股配置 === + stock_list: List[str] = field(default_factory=list) + + # === 数据源 API Token === + tushare_token: Optional[str] = None + + # === AI 分析配置 === + gemini_api_key: Optional[str] = None + gemini_model: str = "gemini-3-flash-preview" # 主模型 + gemini_model_fallback: str = "gemini-2.5-flash" # 备选模型 + + # Gemini API 请求配置(防止 429 限流) + gemini_request_delay: float = 2.0 # 请求间隔(秒) + gemini_max_retries: int = 5 # 最大重试次数 + gemini_retry_delay: float = 5.0 # 重试基础延时(秒) + + # === 搜索引擎配置(支持多 Key 负载均衡)=== + tavily_api_keys: List[str] = field(default_factory=list) # Tavily API Keys + serpapi_keys: List[str] = field(default_factory=list) # SerpAPI Keys + + # === 通知配置 === + wechat_webhook_url: Optional[str] = None + + # === 数据库配置 === + database_path: str = "./data/stock_analysis.db" + + # === 日志配置 === + log_dir: str = "./logs" # 日志文件目录 + log_level: str = "INFO" # 日志级别 + + # === 系统配置 === + max_workers: int = 3 # 低并发防封禁 + debug: bool = False + + # === 定时任务配置 === + schedule_enabled: bool = False # 是否启用定时任务 + schedule_time: str = "18:00" # 每日推送时间(HH:MM 格式) + market_review_enabled: bool = True # 是否启用大盘复盘 + + # === 流控配置(防封禁关键参数)=== + # Akshare 请求间隔范围(秒) + akshare_sleep_min: float = 2.0 + akshare_sleep_max: float = 5.0 + + # Tushare 每分钟最大请求数(免费配额) + tushare_rate_limit_per_minute: int = 80 + + # 重试配置 + max_retries: int = 3 + retry_base_delay: float = 1.0 + retry_max_delay: float = 30.0 + + # 单例实例存储 + _instance: Optional['Config'] = None + + @classmethod + def get_instance(cls) -> 'Config': + """ + 获取配置单例实例 + + 单例模式确保: + 1. 全局只有一个配置实例 + 2. 配置只从环境变量加载一次 + 3. 所有模块共享相同配置 + """ + if cls._instance is None: + cls._instance = cls._load_from_env() + return cls._instance + + @classmethod + def _load_from_env(cls) -> 'Config': + """ + 从 .env 文件加载配置 + + 加载优先级: + 1. 系统环境变量 + 2. .env 文件 + 3. 代码中的默认值 + """ + # 加载项目根目录下的 .env 文件 + env_path = Path(__file__).parent / '.env' + load_dotenv(dotenv_path=env_path) + + # 解析自选股列表(逗号分隔) + stock_list_str = os.getenv('STOCK_LIST', '') + stock_list = [ + code.strip() + for code in stock_list_str.split(',') + if code.strip() + ] + + # 如果没有配置,使用默认的示例股票 + if not stock_list: + stock_list = ['600519', '000001', '300750'] + + # 解析搜索引擎 API Keys(支持多个 key,逗号分隔) + tavily_keys_str = os.getenv('TAVILY_API_KEYS', '') + tavily_api_keys = [k.strip() for k in tavily_keys_str.split(',') if k.strip()] + + serpapi_keys_str = os.getenv('SERPAPI_KEYS', '') + serpapi_keys = [k.strip() for k in serpapi_keys_str.split(',') if k.strip()] + + return cls( + stock_list=stock_list, + tushare_token=os.getenv('TUSHARE_TOKEN'), + gemini_api_key=os.getenv('GEMINI_API_KEY'), + gemini_model=os.getenv('GEMINI_MODEL', 'gemini-3-flash-preview'), + gemini_model_fallback=os.getenv('GEMINI_MODEL_FALLBACK', 'gemini-2.5-flash'), + gemini_request_delay=float(os.getenv('GEMINI_REQUEST_DELAY', '2.0')), + gemini_max_retries=int(os.getenv('GEMINI_MAX_RETRIES', '5')), + gemini_retry_delay=float(os.getenv('GEMINI_RETRY_DELAY', '5.0')), + tavily_api_keys=tavily_api_keys, + serpapi_keys=serpapi_keys, + wechat_webhook_url=os.getenv('WECHAT_WEBHOOK_URL'), + database_path=os.getenv('DATABASE_PATH', './data/stock_analysis.db'), + log_dir=os.getenv('LOG_DIR', './logs'), + log_level=os.getenv('LOG_LEVEL', 'INFO'), + max_workers=int(os.getenv('MAX_WORKERS', '3')), + debug=os.getenv('DEBUG', 'false').lower() == 'true', + schedule_enabled=os.getenv('SCHEDULE_ENABLED', 'false').lower() == 'true', + schedule_time=os.getenv('SCHEDULE_TIME', '18:00'), + market_review_enabled=os.getenv('MARKET_REVIEW_ENABLED', 'true').lower() == 'true', + ) + + @classmethod + def reset_instance(cls) -> None: + """重置单例(主要用于测试)""" + cls._instance = None + + def validate(self) -> List[str]: + """ + 验证配置完整性 + + Returns: + 缺失或无效配置项的警告列表 + """ + warnings = [] + + if not self.stock_list: + warnings.append("警告:未配置自选股列表 (STOCK_LIST)") + + if not self.tushare_token: + warnings.append("提示:未配置 Tushare Token,将使用其他数据源") + + if not self.gemini_api_key: + warnings.append("警告:未配置 Gemini API Key,AI 分析功能将不可用") + + if not self.tavily_api_keys and not self.serpapi_keys: + warnings.append("提示:未配置搜索引擎 API Key (Tavily/SerpAPI),新闻搜索功能将不可用") + + if not self.wechat_webhook_url: + warnings.append("提示:未配置企业微信 Webhook,将不发送推送通知") + + return warnings + + def get_db_url(self) -> str: + """ + 获取 SQLAlchemy 数据库连接 URL + + 自动创建数据库目录(如果不存在) + """ + db_path = Path(self.database_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + return f"sqlite:///{db_path.absolute()}" + + +# === 便捷的配置访问函数 === +def get_config() -> Config: + """获取全局配置实例的快捷方式""" + return Config.get_instance() + + +if __name__ == "__main__": + # 测试配置加载 + config = get_config() + print("=== 配置加载测试 ===") + print(f"自选股列表: {config.stock_list}") + print(f"数据库路径: {config.database_path}") + print(f"最大并发数: {config.max_workers}") + print(f"调试模式: {config.debug}") + + # 验证配置 + warnings = config.validate() + if warnings: + print("\n配置验证结果:") + for w in warnings: + print(f" - {w}") diff --git a/data_provider/__init__.py b/data_provider/__init__.py new file mode 100644 index 000000000..cd0f5c62b --- /dev/null +++ b/data_provider/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +""" +=================================== +数据源策略层 - 包初始化 +=================================== + +本包实现策略模式管理多个数据源,实现: +1. 统一的数据获取接口 +2. 自动故障切换 +3. 防封禁流控策略 +""" + +from .base import BaseFetcher, DataFetcherManager +from .akshare_fetcher import AkshareFetcher +from .tushare_fetcher import TushareFetcher +from .baostock_fetcher import BaostockFetcher +from .yfinance_fetcher import YfinanceFetcher + +__all__ = [ + 'BaseFetcher', + 'DataFetcherManager', + 'AkshareFetcher', + 'TushareFetcher', + 'BaostockFetcher', + 'YfinanceFetcher', +] diff --git a/data_provider/akshare_fetcher.py b/data_provider/akshare_fetcher.py new file mode 100644 index 000000000..ba1db3c3e --- /dev/null +++ b/data_provider/akshare_fetcher.py @@ -0,0 +1,558 @@ +# -*- coding: utf-8 -*- +""" +=================================== +AkshareFetcher - 主数据源 (Priority 1) +=================================== + +数据来源:东方财富爬虫(通过 akshare 库) +特点:免费、无需 Token、数据全面 +风险:爬虫机制易被反爬封禁 + +防封禁策略: +1. 每次请求前随机休眠 2-5 秒 +2. 随机轮换 User-Agent +3. 使用 tenacity 实现指数退避重试 + +增强数据: +- 实时行情:量比、换手率、市盈率、市净率、总市值、流通市值 +- 筹码分布:获利比例、平均成本、筹码集中度 +""" + +import logging +import random +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional, Dict, Any + +import pandas as pd +from tenacity import ( + retry, + stop_after_attempt, + wait_exponential, + retry_if_exception_type, + before_sleep_log, +) + +from .base import BaseFetcher, DataFetchError, RateLimitError, STANDARD_COLUMNS + + +@dataclass +class RealtimeQuote: + """ + 实时行情数据 + + 包含当日实时交易数据和估值指标 + """ + code: str + name: str = "" + price: float = 0.0 # 最新价 + change_pct: float = 0.0 # 涨跌幅(%) + change_amount: float = 0.0 # 涨跌额 + + # 量价指标 + volume_ratio: float = 0.0 # 量比(当前成交量/过去5日平均成交量) + turnover_rate: float = 0.0 # 换手率(%) + amplitude: float = 0.0 # 振幅(%) + + # 估值指标 + pe_ratio: float = 0.0 # 市盈率(动态) + pb_ratio: float = 0.0 # 市净率 + total_mv: float = 0.0 # 总市值(元) + circ_mv: float = 0.0 # 流通市值(元) + + # 其他 + change_60d: float = 0.0 # 60日涨跌幅(%) + high_52w: float = 0.0 # 52周最高 + low_52w: float = 0.0 # 52周最低 + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + 'code': self.code, + 'name': self.name, + 'price': self.price, + 'change_pct': self.change_pct, + 'volume_ratio': self.volume_ratio, + 'turnover_rate': self.turnover_rate, + 'amplitude': self.amplitude, + 'pe_ratio': self.pe_ratio, + 'pb_ratio': self.pb_ratio, + 'total_mv': self.total_mv, + 'circ_mv': self.circ_mv, + 'change_60d': self.change_60d, + } + + +@dataclass +class ChipDistribution: + """ + 筹码分布数据 + + 反映持仓成本分布和获利情况 + """ + code: str + date: str = "" + + # 获利情况 + profit_ratio: float = 0.0 # 获利比例(0-1) + avg_cost: float = 0.0 # 平均成本 + + # 筹码集中度 + cost_90_low: float = 0.0 # 90%筹码成本下限 + cost_90_high: float = 0.0 # 90%筹码成本上限 + concentration_90: float = 0.0 # 90%筹码集中度(越小越集中) + + cost_70_low: float = 0.0 # 70%筹码成本下限 + cost_70_high: float = 0.0 # 70%筹码成本上限 + concentration_70: float = 0.0 # 70%筹码集中度 + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + 'code': self.code, + 'date': self.date, + 'profit_ratio': self.profit_ratio, + 'avg_cost': self.avg_cost, + 'cost_90_low': self.cost_90_low, + 'cost_90_high': self.cost_90_high, + 'concentration_90': self.concentration_90, + 'concentration_70': self.concentration_70, + } + + def get_chip_status(self, current_price: float) -> str: + """ + 获取筹码状态描述 + + Args: + current_price: 当前股价 + + Returns: + 筹码状态描述 + """ + status_parts = [] + + # 获利比例分析 + if self.profit_ratio >= 0.9: + status_parts.append("获利盘极高(>90%)") + elif self.profit_ratio >= 0.7: + status_parts.append("获利盘较高(70-90%)") + elif self.profit_ratio >= 0.5: + status_parts.append("获利盘中等(50-70%)") + elif self.profit_ratio >= 0.3: + status_parts.append("套牢盘较多(>30%)") + else: + status_parts.append("套牢盘极重(>70%)") + + # 筹码集中度分析 (90%集中度 < 10% 表示集中) + if self.concentration_90 < 0.08: + status_parts.append("筹码高度集中") + elif self.concentration_90 < 0.15: + status_parts.append("筹码较集中") + elif self.concentration_90 < 0.25: + status_parts.append("筹码分散度中等") + else: + status_parts.append("筹码较分散") + + # 成本与现价关系 + if current_price > 0 and self.avg_cost > 0: + cost_diff = (current_price - self.avg_cost) / self.avg_cost * 100 + if cost_diff > 20: + status_parts.append(f"现价高于平均成本{cost_diff:.1f}%") + elif cost_diff > 5: + status_parts.append(f"现价略高于成本{cost_diff:.1f}%") + elif cost_diff > -5: + status_parts.append("现价接近平均成本") + else: + status_parts.append(f"现价低于平均成本{abs(cost_diff):.1f}%") + + return ",".join(status_parts) + +logger = logging.getLogger(__name__) + + +# User-Agent 池,用于随机轮换 +USER_AGENTS = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15', + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', +] + + +# 缓存实时行情数据(避免重复请求) +_realtime_cache: Dict[str, Any] = { + 'data': None, + 'timestamp': 0, + 'ttl': 60 # 60秒缓存有效期 +} + + +class AkshareFetcher(BaseFetcher): + """ + Akshare 数据源实现 + + 优先级:1(最高) + 数据来源:东方财富网爬虫 + + 关键策略: + - 每次请求前随机休眠 2.0-5.0 秒 + - 随机 User-Agent 轮换 + - 失败后指数退避重试(最多3次) + """ + + name = "AkshareFetcher" + priority = 1 + + def __init__(self, sleep_min: float = 2.0, sleep_max: float = 5.0): + """ + 初始化 AkshareFetcher + + Args: + sleep_min: 最小休眠时间(秒) + sleep_max: 最大休眠时间(秒) + """ + self.sleep_min = sleep_min + self.sleep_max = sleep_max + self._last_request_time: Optional[float] = None + + def _set_random_user_agent(self) -> None: + """ + 设置随机 User-Agent + + 通过修改 requests Session 的 headers 实现 + 这是关键的反爬策略之一 + """ + try: + import akshare as ak + # akshare 内部使用 requests,我们通过环境变量或直接设置来影响 + # 实际上 akshare 可能不直接暴露 session,这里通过 fake_useragent 作为补充 + random_ua = random.choice(USER_AGENTS) + logger.debug(f"设置 User-Agent: {random_ua[:50]}...") + except Exception as e: + logger.debug(f"设置 User-Agent 失败: {e}") + + def _enforce_rate_limit(self) -> None: + """ + 强制执行速率限制 + + 策略: + 1. 检查距离上次请求的时间间隔 + 2. 如果间隔不足,补充休眠时间 + 3. 然后再执行随机 jitter 休眠 + """ + if self._last_request_time is not None: + elapsed = time.time() - self._last_request_time + min_interval = self.sleep_min + if elapsed < min_interval: + additional_sleep = min_interval - elapsed + logger.debug(f"补充休眠 {additional_sleep:.2f} 秒") + time.sleep(additional_sleep) + + # 执行随机 jitter 休眠 + self.random_sleep(self.sleep_min, self.sleep_max) + self._last_request_time = time.time() + + @retry( + stop=stop_after_attempt(3), # 最多重试3次 + wait=wait_exponential(multiplier=1, min=2, max=30), # 指数退避:2, 4, 8... 最大30秒 + retry=retry_if_exception_type((ConnectionError, TimeoutError)), + before_sleep=before_sleep_log(logger, logging.WARNING), + ) + def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame: + """ + 从 Akshare 获取原始数据 + + 使用 ak.stock_zh_a_hist() 获取 A 股历史数据 + + 流程: + 1. 设置随机 User-Agent + 2. 执行速率限制(随机休眠) + 3. 调用 akshare API + 4. 处理返回数据 + """ + import akshare as ak + + # 防封禁策略 1: 随机 User-Agent + self._set_random_user_agent() + + # 防封禁策略 2: 强制休眠 + self._enforce_rate_limit() + + logger.info(f"[API调用] ak.stock_zh_a_hist(symbol={stock_code}, period=daily, " + f"start_date={start_date.replace('-', '')}, end_date={end_date.replace('-', '')}, adjust=qfq)") + + try: + # 调用 akshare 获取 A 股日线数据 + # period="daily" 获取日线数据 + # adjust="qfq" 获取前复权数据 + import time as _time + api_start = _time.time() + + df = ak.stock_zh_a_hist( + symbol=stock_code, + period="daily", + start_date=start_date.replace('-', ''), + end_date=end_date.replace('-', ''), + adjust="qfq" # 前复权 + ) + + api_elapsed = _time.time() - api_start + + # 记录返回数据摘要 + if df is not None and not df.empty: + logger.info(f"[API返回] ak.stock_zh_a_hist 成功: 返回 {len(df)} 行数据, 耗时 {api_elapsed:.2f}s") + logger.info(f"[API返回] 列名: {list(df.columns)}") + logger.info(f"[API返回] 日期范围: {df['日期'].iloc[0]} ~ {df['日期'].iloc[-1]}") + logger.debug(f"[API返回] 最新3条数据:\n{df.tail(3).to_string()}") + else: + logger.warning(f"[API返回] ak.stock_zh_a_hist 返回空数据, 耗时 {api_elapsed:.2f}s") + + return df + + except Exception as e: + error_msg = str(e).lower() + + # 检测反爬封禁 + if any(keyword in error_msg for keyword in ['banned', 'blocked', '频率', 'rate', '限制']): + logger.warning(f"检测到可能被封禁: {e}") + raise RateLimitError(f"Akshare 可能被限流: {e}") from e + + raise DataFetchError(f"Akshare 获取数据失败: {e}") from e + + def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame: + """ + 标准化 Akshare 数据 + + Akshare 返回的列名(中文): + 日期, 开盘, 收盘, 最高, 最低, 成交量, 成交额, 振幅, 涨跌幅, 涨跌额, 换手率 + + 需要映射到标准列名: + date, open, high, low, close, volume, amount, pct_chg + """ + df = df.copy() + + # 列名映射(Akshare 中文列名 -> 标准英文列名) + column_mapping = { + '日期': 'date', + '开盘': 'open', + '收盘': 'close', + '最高': 'high', + '最低': 'low', + '成交量': 'volume', + '成交额': 'amount', + '涨跌幅': 'pct_chg', + } + + # 重命名列 + df = df.rename(columns=column_mapping) + + # 添加股票代码列 + df['code'] = stock_code + + # 只保留需要的列 + keep_cols = ['code'] + STANDARD_COLUMNS + existing_cols = [col for col in keep_cols if col in df.columns] + df = df[existing_cols] + + return df + + def get_realtime_quote(self, stock_code: str) -> Optional[RealtimeQuote]: + """ + 获取实时行情数据 + + 数据来源:ak.stock_zh_a_spot_em() + 包含:量比、换手率、市盈率、市净率、总市值、流通市值等 + + Args: + stock_code: 股票代码 + + Returns: + RealtimeQuote 对象,获取失败返回 None + """ + import akshare as ak + + try: + # 检查缓存 + current_time = time.time() + if (_realtime_cache['data'] is not None and + current_time - _realtime_cache['timestamp'] < _realtime_cache['ttl']): + df = _realtime_cache['data'] + logger.debug(f"[缓存命中] 使用缓存的实时行情数据") + else: + # 防封禁策略 + self._set_random_user_agent() + self._enforce_rate_limit() + + logger.info(f"[API调用] ak.stock_zh_a_spot_em() 获取A股实时行情...") + import time as _time + api_start = _time.time() + + df = ak.stock_zh_a_spot_em() + + api_elapsed = _time.time() - api_start + logger.info(f"[API返回] ak.stock_zh_a_spot_em 成功: 返回 {len(df)} 只股票, 耗时 {api_elapsed:.2f}s") + + # 更新缓存 + _realtime_cache['data'] = df + _realtime_cache['timestamp'] = current_time + + # 查找指定股票 + row = df[df['代码'] == stock_code] + if row.empty: + logger.warning(f"[API返回] 未找到股票 {stock_code} 的实时行情") + return None + + row = row.iloc[0] + + # 安全获取字段值 + def safe_float(val, default=0.0): + try: + if pd.isna(val): + return default + return float(val) + except: + return default + + quote = RealtimeQuote( + code=stock_code, + name=str(row.get('名称', '')), + price=safe_float(row.get('最新价')), + change_pct=safe_float(row.get('涨跌幅')), + change_amount=safe_float(row.get('涨跌额')), + volume_ratio=safe_float(row.get('量比')), + turnover_rate=safe_float(row.get('换手率')), + amplitude=safe_float(row.get('振幅')), + pe_ratio=safe_float(row.get('市盈率-动态')), + pb_ratio=safe_float(row.get('市净率')), + total_mv=safe_float(row.get('总市值')), + circ_mv=safe_float(row.get('流通市值')), + change_60d=safe_float(row.get('60日涨跌幅')), + high_52w=safe_float(row.get('52周最高')), + low_52w=safe_float(row.get('52周最低')), + ) + + logger.info(f"[实时行情] {stock_code} {quote.name}: 价格={quote.price}, 涨跌={quote.change_pct}%, " + f"量比={quote.volume_ratio}, 换手率={quote.turnover_rate}%, " + f"PE={quote.pe_ratio}, PB={quote.pb_ratio}") + return quote + + except Exception as e: + logger.error(f"[API错误] 获取 {stock_code} 实时行情失败: {e}") + return None + + def get_chip_distribution(self, stock_code: str) -> Optional[ChipDistribution]: + """ + 获取筹码分布数据 + + 数据来源:ak.stock_cyq_em() + 包含:获利比例、平均成本、筹码集中度 + + Args: + stock_code: 股票代码 + + Returns: + ChipDistribution 对象(最新一天的数据),获取失败返回 None + """ + import akshare as ak + + try: + # 防封禁策略 + self._set_random_user_agent() + self._enforce_rate_limit() + + logger.info(f"[API调用] ak.stock_cyq_em(symbol={stock_code}) 获取筹码分布...") + import time as _time + api_start = _time.time() + + df = ak.stock_cyq_em(symbol=stock_code) + + api_elapsed = _time.time() - api_start + + if df.empty: + logger.warning(f"[API返回] ak.stock_cyq_em 返回空数据, 耗时 {api_elapsed:.2f}s") + return None + + logger.info(f"[API返回] ak.stock_cyq_em 成功: 返回 {len(df)} 天数据, 耗时 {api_elapsed:.2f}s") + logger.debug(f"[API返回] 筹码数据列名: {list(df.columns)}") + + # 取最新一天的数据 + latest = df.iloc[-1] + + def safe_float(val, default=0.0): + try: + if pd.isna(val): + return default + return float(val) + except: + return default + + chip = ChipDistribution( + code=stock_code, + date=str(latest.get('日期', '')), + profit_ratio=safe_float(latest.get('获利比例')), + avg_cost=safe_float(latest.get('平均成本')), + cost_90_low=safe_float(latest.get('90成本-低')), + cost_90_high=safe_float(latest.get('90成本-高')), + concentration_90=safe_float(latest.get('90集中度')), + cost_70_low=safe_float(latest.get('70成本-低')), + cost_70_high=safe_float(latest.get('70成本-高')), + concentration_70=safe_float(latest.get('70集中度')), + ) + + logger.info(f"[筹码分布] {stock_code} 日期={chip.date}: 获利比例={chip.profit_ratio:.1%}, " + f"平均成本={chip.avg_cost}, 90%集中度={chip.concentration_90:.2%}, " + f"70%集中度={chip.concentration_70:.2%}") + return chip + + except Exception as e: + logger.error(f"[API错误] 获取 {stock_code} 筹码分布失败: {e}") + return None + + def get_enhanced_data(self, stock_code: str, days: int = 60) -> Dict[str, Any]: + """ + 获取增强数据(历史K线 + 实时行情 + 筹码分布) + + Args: + stock_code: 股票代码 + days: 历史数据天数 + + Returns: + 包含所有数据的字典 + """ + result = { + 'code': stock_code, + 'daily_data': None, + 'realtime_quote': None, + 'chip_distribution': None, + } + + # 获取日线数据 + try: + df = self.get_daily_data(stock_code, days=days) + result['daily_data'] = df + except Exception as e: + logger.error(f"获取 {stock_code} 日线数据失败: {e}") + + # 获取实时行情 + result['realtime_quote'] = self.get_realtime_quote(stock_code) + + # 获取筹码分布 + result['chip_distribution'] = self.get_chip_distribution(stock_code) + + return result + + +if __name__ == "__main__": + # 测试代码 + logging.basicConfig(level=logging.DEBUG) + + fetcher = AkshareFetcher() + + try: + df = fetcher.get_daily_data('600519') # 茅台 + print(f"获取成功,共 {len(df)} 条数据") + print(df.tail()) + except Exception as e: + print(f"获取失败: {e}") diff --git a/data_provider/baostock_fetcher.py b/data_provider/baostock_fetcher.py new file mode 100644 index 000000000..0d5c405ce --- /dev/null +++ b/data_provider/baostock_fetcher.py @@ -0,0 +1,246 @@ +# -*- coding: utf-8 -*- +""" +=================================== +BaostockFetcher - 备用数据源 2 (Priority 3) +=================================== + +数据来源:证券宝(Baostock) +特点:免费、无需 Token、需要登录管理 +优点:稳定、无配额限制 + +关键策略: +1. 管理 bs.login() 和 bs.logout() 生命周期 +2. 使用上下文管理器防止连接泄露 +3. 失败后指数退避重试 +""" + +import logging +from contextlib import contextmanager +from datetime import datetime +from typing import Optional, Generator + +import pandas as pd +from tenacity import ( + retry, + stop_after_attempt, + wait_exponential, + retry_if_exception_type, + before_sleep_log, +) + +from .base import BaseFetcher, DataFetchError, STANDARD_COLUMNS + +logger = logging.getLogger(__name__) + + +class BaostockFetcher(BaseFetcher): + """ + Baostock 数据源实现 + + 优先级:3 + 数据来源:证券宝 Baostock API + + 关键策略: + - 使用上下文管理器管理连接生命周期 + - 每次请求都重新登录/登出,防止连接泄露 + - 失败后指数退避重试 + + Baostock 特点: + - 免费、无需注册 + - 需要显式登录/登出 + - 数据更新略有延迟(T+1) + """ + + name = "BaostockFetcher" + priority = 3 + + def __init__(self): + """初始化 BaostockFetcher""" + self._bs_module = None + + def _get_baostock(self): + """ + 延迟加载 baostock 模块 + + 只在首次使用时导入,避免未安装时报错 + """ + if self._bs_module is None: + import baostock as bs + self._bs_module = bs + return self._bs_module + + @contextmanager + def _baostock_session(self) -> Generator: + """ + Baostock 连接上下文管理器 + + 确保: + 1. 进入上下文时自动登录 + 2. 退出上下文时自动登出 + 3. 异常时也能正确登出 + + 使用示例: + with self._baostock_session(): + # 在这里执行数据查询 + """ + bs = self._get_baostock() + login_result = None + + try: + # 登录 Baostock + login_result = bs.login() + + if login_result.error_code != '0': + raise DataFetchError(f"Baostock 登录失败: {login_result.error_msg}") + + logger.debug("Baostock 登录成功") + + yield bs + + finally: + # 确保登出,防止连接泄露 + try: + logout_result = bs.logout() + if logout_result.error_code == '0': + logger.debug("Baostock 登出成功") + else: + logger.warning(f"Baostock 登出异常: {logout_result.error_msg}") + except Exception as e: + logger.warning(f"Baostock 登出时发生错误: {e}") + + def _convert_stock_code(self, stock_code: str) -> str: + """ + 转换股票代码为 Baostock 格式 + + Baostock 要求的格式: + - 沪市:sh.600519 + - 深市:sz.000001 + + Args: + stock_code: 原始代码,如 '600519', '000001' + + Returns: + Baostock 格式代码,如 'sh.600519', 'sz.000001' + """ + code = stock_code.strip() + + # 已经包含前缀的情况 + if code.startswith(('sh.', 'sz.')): + return code.lower() + + # 去除可能的后缀 + code = code.replace('.SH', '').replace('.SZ', '').replace('.sh', '').replace('.sz', '') + + # 根据代码前缀判断市场 + if code.startswith(('600', '601', '603', '688')): + return f"sh.{code}" + elif code.startswith(('000', '002', '300')): + return f"sz.{code}" + else: + logger.warning(f"无法确定股票 {code} 的市场,默认使用深市") + return f"sz.{code}" + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=2, max=30), + retry=retry_if_exception_type((ConnectionError, TimeoutError)), + before_sleep=before_sleep_log(logger, logging.WARNING), + ) + def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame: + """ + 从 Baostock 获取原始数据 + + 使用 query_history_k_data_plus() 获取日线数据 + + 流程: + 1. 使用上下文管理器管理连接 + 2. 转换股票代码格式 + 3. 调用 API 查询数据 + 4. 将结果转换为 DataFrame + """ + # 转换代码格式 + bs_code = self._convert_stock_code(stock_code) + + logger.debug(f"调用 Baostock query_history_k_data_plus({bs_code}, {start_date}, {end_date})") + + with self._baostock_session() as bs: + try: + # 查询日线数据 + # adjustflag: 1-后复权,2-前复权,3-不复权 + rs = bs.query_history_k_data_plus( + code=bs_code, + fields="date,open,high,low,close,volume,amount,pctChg", + start_date=start_date, + end_date=end_date, + frequency="d", # 日线 + adjustflag="2" # 前复权 + ) + + if rs.error_code != '0': + raise DataFetchError(f"Baostock 查询失败: {rs.error_msg}") + + # 转换为 DataFrame + data_list = [] + while rs.next(): + data_list.append(rs.get_row_data()) + + if not data_list: + raise DataFetchError(f"Baostock 未查询到 {stock_code} 的数据") + + df = pd.DataFrame(data_list, columns=rs.fields) + + return df + + except Exception as e: + if isinstance(e, DataFetchError): + raise + raise DataFetchError(f"Baostock 获取数据失败: {e}") from e + + def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame: + """ + 标准化 Baostock 数据 + + Baostock 返回的列名: + date, open, high, low, close, volume, amount, pctChg + + 需要映射到标准列名: + date, open, high, low, close, volume, amount, pct_chg + """ + df = df.copy() + + # 列名映射(只需要处理 pctChg) + column_mapping = { + 'pctChg': 'pct_chg', + } + + df = df.rename(columns=column_mapping) + + # 数值类型转换(Baostock 返回的都是字符串) + numeric_cols = ['open', 'high', 'low', 'close', 'volume', 'amount', 'pct_chg'] + for col in numeric_cols: + if col in df.columns: + df[col] = pd.to_numeric(df[col], errors='coerce') + + # 添加股票代码列 + df['code'] = stock_code + + # 只保留需要的列 + keep_cols = ['code'] + STANDARD_COLUMNS + existing_cols = [col for col in keep_cols if col in df.columns] + df = df[existing_cols] + + return df + + +if __name__ == "__main__": + # 测试代码 + logging.basicConfig(level=logging.DEBUG) + + fetcher = BaostockFetcher() + + try: + df = fetcher.get_daily_data('600519') # 茅台 + print(f"获取成功,共 {len(df)} 条数据") + print(df.tail()) + except Exception as e: + print(f"获取失败: {e}") diff --git a/data_provider/base.py b/data_provider/base.py new file mode 100644 index 000000000..6409e61c7 --- /dev/null +++ b/data_provider/base.py @@ -0,0 +1,351 @@ +# -*- coding: utf-8 -*- +""" +=================================== +数据源基类与管理器 +=================================== + +设计模式:策略模式 (Strategy Pattern) +- BaseFetcher: 抽象基类,定义统一接口 +- DataFetcherManager: 策略管理器,实现自动切换 + +防封禁策略: +1. 每个 Fetcher 内置流控逻辑 +2. 失败自动切换到下一个数据源 +3. 指数退避重试机制 +""" + +import logging +import random +import time +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Optional, List, Tuple + +import pandas as pd +import numpy as np +from tenacity import ( + retry, + stop_after_attempt, + wait_exponential, + retry_if_exception_type, +) + +# 配置日志 +logger = logging.getLogger(__name__) + + +# === 标准化列名定义 === +STANDARD_COLUMNS = ['date', 'open', 'high', 'low', 'close', 'volume', 'amount', 'pct_chg'] + + +class DataFetchError(Exception): + """数据获取异常基类""" + pass + + +class RateLimitError(DataFetchError): + """API 速率限制异常""" + pass + + +class DataSourceUnavailableError(DataFetchError): + """数据源不可用异常""" + pass + + +class BaseFetcher(ABC): + """ + 数据源抽象基类 + + 职责: + 1. 定义统一的数据获取接口 + 2. 提供数据标准化方法 + 3. 实现通用的技术指标计算 + + 子类实现: + - _fetch_raw_data(): 从具体数据源获取原始数据 + - _normalize_data(): 将原始数据转换为标准格式 + """ + + name: str = "BaseFetcher" + priority: int = 99 # 优先级数字越小越优先 + + @abstractmethod + def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame: + """ + 从数据源获取原始数据(子类必须实现) + + Args: + stock_code: 股票代码,如 '600519', '000001' + start_date: 开始日期,格式 'YYYY-MM-DD' + end_date: 结束日期,格式 'YYYY-MM-DD' + + Returns: + 原始数据 DataFrame(列名因数据源而异) + """ + pass + + @abstractmethod + def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame: + """ + 标准化数据列名(子类必须实现) + + 将不同数据源的列名统一为: + ['date', 'open', 'high', 'low', 'close', 'volume', 'amount', 'pct_chg'] + """ + pass + + def get_daily_data( + self, + stock_code: str, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + days: int = 30 + ) -> pd.DataFrame: + """ + 获取日线数据(统一入口) + + 流程: + 1. 计算日期范围 + 2. 调用子类获取原始数据 + 3. 标准化列名 + 4. 计算技术指标 + + Args: + stock_code: 股票代码 + start_date: 开始日期(可选) + end_date: 结束日期(可选,默认今天) + days: 获取天数(当 start_date 未指定时使用) + + Returns: + 标准化的 DataFrame,包含技术指标 + """ + # 计算日期范围 + if end_date is None: + end_date = datetime.now().strftime('%Y-%m-%d') + + if start_date is None: + # 默认获取最近 30 个交易日(按日历日估算,多取一些) + from datetime import timedelta + start_dt = datetime.strptime(end_date, '%Y-%m-%d') - timedelta(days=days * 2) + start_date = start_dt.strftime('%Y-%m-%d') + + logger.info(f"[{self.name}] 获取 {stock_code} 数据: {start_date} ~ {end_date}") + + try: + # Step 1: 获取原始数据 + raw_df = self._fetch_raw_data(stock_code, start_date, end_date) + + if raw_df is None or raw_df.empty: + raise DataFetchError(f"[{self.name}] 未获取到 {stock_code} 的数据") + + # Step 2: 标准化列名 + df = self._normalize_data(raw_df, stock_code) + + # Step 3: 数据清洗 + df = self._clean_data(df) + + # Step 4: 计算技术指标 + df = self._calculate_indicators(df) + + logger.info(f"[{self.name}] {stock_code} 获取成功,共 {len(df)} 条数据") + return df + + except Exception as e: + logger.error(f"[{self.name}] 获取 {stock_code} 失败: {str(e)}") + raise DataFetchError(f"[{self.name}] {stock_code}: {str(e)}") from e + + def _clean_data(self, df: pd.DataFrame) -> pd.DataFrame: + """ + 数据清洗 + + 处理: + 1. 确保日期列格式正确 + 2. 数值类型转换 + 3. 去除空值行 + 4. 按日期排序 + """ + df = df.copy() + + # 确保日期列为 datetime 类型 + if 'date' in df.columns: + df['date'] = pd.to_datetime(df['date']) + + # 数值列类型转换 + numeric_cols = ['open', 'high', 'low', 'close', 'volume', 'amount', 'pct_chg'] + for col in numeric_cols: + if col in df.columns: + df[col] = pd.to_numeric(df[col], errors='coerce') + + # 去除关键列为空的行 + df = df.dropna(subset=['close', 'volume']) + + # 按日期升序排序 + df = df.sort_values('date', ascending=True).reset_index(drop=True) + + return df + + def _calculate_indicators(self, df: pd.DataFrame) -> pd.DataFrame: + """ + 计算技术指标 + + 计算指标: + - MA5, MA10, MA20: 移动平均线 + - Volume_Ratio: 量比(今日成交量 / 5日平均成交量) + """ + df = df.copy() + + # 移动平均线 + df['ma5'] = df['close'].rolling(window=5, min_periods=1).mean() + df['ma10'] = df['close'].rolling(window=10, min_periods=1).mean() + df['ma20'] = df['close'].rolling(window=20, min_periods=1).mean() + + # 量比:当日成交量 / 5日平均成交量 + avg_volume_5 = df['volume'].rolling(window=5, min_periods=1).mean() + df['volume_ratio'] = df['volume'] / avg_volume_5.shift(1) + df['volume_ratio'] = df['volume_ratio'].fillna(1.0) + + # 保留2位小数 + for col in ['ma5', 'ma10', 'ma20', 'volume_ratio']: + if col in df.columns: + df[col] = df[col].round(2) + + return df + + @staticmethod + def random_sleep(min_seconds: float = 1.0, max_seconds: float = 3.0) -> None: + """ + 智能随机休眠(Jitter) + + 防封禁策略:模拟人类行为的随机延迟 + 在请求之间加入不规则的等待时间 + """ + sleep_time = random.uniform(min_seconds, max_seconds) + logger.debug(f"随机休眠 {sleep_time:.2f} 秒...") + time.sleep(sleep_time) + + +class DataFetcherManager: + """ + 数据源策略管理器 + + 职责: + 1. 管理多个数据源(按优先级排序) + 2. 自动故障切换(Failover) + 3. 提供统一的数据获取接口 + + 切换策略: + - 优先使用高优先级数据源 + - 失败后自动切换到下一个 + - 所有数据源都失败时抛出异常 + """ + + def __init__(self, fetchers: Optional[List[BaseFetcher]] = None): + """ + 初始化管理器 + + Args: + fetchers: 数据源列表(可选,默认按优先级自动创建) + """ + self._fetchers: List[BaseFetcher] = [] + + if fetchers: + # 按优先级排序 + self._fetchers = sorted(fetchers, key=lambda f: f.priority) + else: + # 默认数据源将在首次使用时延迟加载 + self._init_default_fetchers() + + def _init_default_fetchers(self) -> None: + """ + 初始化默认数据源列表 + + 按优先级排序: + 1. AkshareFetcher (Priority 1) + 2. TushareFetcher (Priority 2) + 3. BaostockFetcher (Priority 3) + 4. YfinanceFetcher (Priority 4) + """ + from .akshare_fetcher import AkshareFetcher + from .tushare_fetcher import TushareFetcher + from .baostock_fetcher import BaostockFetcher + from .yfinance_fetcher import YfinanceFetcher + + self._fetchers = [ + AkshareFetcher(), + TushareFetcher(), + BaostockFetcher(), + YfinanceFetcher(), + ] + + # 按优先级排序 + self._fetchers.sort(key=lambda f: f.priority) + + logger.info(f"已初始化 {len(self._fetchers)} 个数据源: " + + ", ".join([f.name for f in self._fetchers])) + + def add_fetcher(self, fetcher: BaseFetcher) -> None: + """添加数据源并重新排序""" + self._fetchers.append(fetcher) + self._fetchers.sort(key=lambda f: f.priority) + + def get_daily_data( + self, + stock_code: str, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + days: int = 30 + ) -> Tuple[pd.DataFrame, str]: + """ + 获取日线数据(自动切换数据源) + + 故障切换策略: + 1. 从最高优先级数据源开始尝试 + 2. 捕获异常后自动切换到下一个 + 3. 记录每个数据源的失败原因 + 4. 所有数据源失败后抛出详细异常 + + Args: + stock_code: 股票代码 + start_date: 开始日期 + end_date: 结束日期 + days: 获取天数 + + Returns: + Tuple[DataFrame, str]: (数据, 成功的数据源名称) + + Raises: + DataFetchError: 所有数据源都失败时抛出 + """ + errors = [] + + for fetcher in self._fetchers: + try: + logger.info(f"尝试使用 [{fetcher.name}] 获取 {stock_code}...") + df = fetcher.get_daily_data( + stock_code=stock_code, + start_date=start_date, + end_date=end_date, + days=days + ) + + if df is not None and not df.empty: + logger.info(f"[{fetcher.name}] 成功获取 {stock_code}") + return df, fetcher.name + + except Exception as e: + error_msg = f"[{fetcher.name}] 失败: {str(e)}" + logger.warning(error_msg) + errors.append(error_msg) + # 继续尝试下一个数据源 + continue + + # 所有数据源都失败 + error_summary = f"所有数据源获取 {stock_code} 失败:\n" + "\n".join(errors) + logger.error(error_summary) + raise DataFetchError(error_summary) + + @property + def available_fetchers(self) -> List[str]: + """返回可用数据源名称列表""" + return [f.name for f in self._fetchers] diff --git a/data_provider/tushare_fetcher.py b/data_provider/tushare_fetcher.py new file mode 100644 index 000000000..f46486db4 --- /dev/null +++ b/data_provider/tushare_fetcher.py @@ -0,0 +1,281 @@ +# -*- coding: utf-8 -*- +""" +=================================== +TushareFetcher - 备用数据源 1 (Priority 2) +=================================== + +数据来源:Tushare Pro API(挖地兔) +特点:需要 Token、有请求配额限制 +优点:数据质量高、接口稳定 + +流控策略: +1. 实现"每分钟调用计数器" +2. 超过免费配额(80次/分)时,强制休眠到下一分钟 +3. 使用 tenacity 实现指数退避重试 +""" + +import logging +import time +from datetime import datetime +from typing import Optional, Tuple + +import pandas as pd +from tenacity import ( + retry, + stop_after_attempt, + wait_exponential, + retry_if_exception_type, + before_sleep_log, +) + +from .base import BaseFetcher, DataFetchError, RateLimitError, STANDARD_COLUMNS +from config import get_config + +logger = logging.getLogger(__name__) + + +class TushareFetcher(BaseFetcher): + """ + Tushare Pro 数据源实现 + + 优先级:2 + 数据来源:Tushare Pro API + + 关键策略: + - 每分钟调用计数器,防止超出配额 + - 超过 80 次/分钟时强制等待 + - 失败后指数退避重试 + + 配额说明(Tushare 免费用户): + - 每分钟最多 80 次请求 + - 每天最多 500 次请求 + """ + + name = "TushareFetcher" + priority = 2 + + def __init__(self, rate_limit_per_minute: int = 80): + """ + 初始化 TushareFetcher + + Args: + rate_limit_per_minute: 每分钟最大请求数(默认80,Tushare免费配额) + """ + self.rate_limit_per_minute = rate_limit_per_minute + self._call_count = 0 # 当前分钟内的调用次数 + self._minute_start: Optional[float] = None # 当前计数周期开始时间 + self._api: Optional[object] = None # Tushare API 实例 + + # 尝试初始化 API + self._init_api() + + def _init_api(self) -> None: + """ + 初始化 Tushare API + + 如果 Token 未配置,此数据源将不可用 + """ + config = get_config() + + if not config.tushare_token: + logger.warning("Tushare Token 未配置,此数据源不可用") + return + + try: + import tushare as ts + + # 设置 Token + ts.set_token(config.tushare_token) + + # 获取 API 实例 + self._api = ts.pro_api() + + logger.info("Tushare API 初始化成功") + + except Exception as e: + logger.error(f"Tushare API 初始化失败: {e}") + self._api = None + + def _check_rate_limit(self) -> None: + """ + 检查并执行速率限制 + + 流控策略: + 1. 检查是否进入新的一分钟 + 2. 如果是,重置计数器 + 3. 如果当前分钟调用次数超过限制,强制休眠 + """ + current_time = time.time() + + # 检查是否需要重置计数器(新的一分钟) + if self._minute_start is None: + self._minute_start = current_time + self._call_count = 0 + elif current_time - self._minute_start >= 60: + # 已经过了一分钟,重置计数器 + self._minute_start = current_time + self._call_count = 0 + logger.debug("速率限制计数器已重置") + + # 检查是否超过配额 + if self._call_count >= self.rate_limit_per_minute: + # 计算需要等待的时间(到下一分钟) + elapsed = current_time - self._minute_start + sleep_time = max(0, 60 - elapsed) + 1 # +1 秒缓冲 + + logger.warning( + f"Tushare 达到速率限制 ({self._call_count}/{self.rate_limit_per_minute} 次/分钟)," + f"等待 {sleep_time:.1f} 秒..." + ) + + time.sleep(sleep_time) + + # 重置计数器 + self._minute_start = time.time() + self._call_count = 0 + + # 增加调用计数 + self._call_count += 1 + logger.debug(f"Tushare 当前分钟调用次数: {self._call_count}/{self.rate_limit_per_minute}") + + def _convert_stock_code(self, stock_code: str) -> str: + """ + 转换股票代码为 Tushare 格式 + + Tushare 要求的格式: + - 沪市:600519.SH + - 深市:000001.SZ + + Args: + stock_code: 原始代码,如 '600519', '000001' + + Returns: + Tushare 格式代码,如 '600519.SH', '000001.SZ' + """ + code = stock_code.strip() + + # 已经包含后缀的情况 + if '.' in code: + return code.upper() + + # 根据代码前缀判断市场 + # 沪市:600xxx, 601xxx, 603xxx, 688xxx (科创板) + # 深市:000xxx, 002xxx, 300xxx (创业板) + if code.startswith(('600', '601', '603', '688')): + return f"{code}.SH" + elif code.startswith(('000', '002', '300')): + return f"{code}.SZ" + else: + # 默认尝试深市 + logger.warning(f"无法确定股票 {code} 的市场,默认使用深市") + return f"{code}.SZ" + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=2, max=30), + retry=retry_if_exception_type((ConnectionError, TimeoutError)), + before_sleep=before_sleep_log(logger, logging.WARNING), + ) + def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame: + """ + 从 Tushare 获取原始数据 + + 使用 daily() 接口获取日线数据 + + 流程: + 1. 检查 API 是否可用 + 2. 执行速率限制检查 + 3. 转换股票代码格式 + 4. 调用 API 获取数据 + """ + if self._api is None: + raise DataFetchError("Tushare API 未初始化,请检查 Token 配置") + + # 速率限制检查 + self._check_rate_limit() + + # 转换代码格式 + ts_code = self._convert_stock_code(stock_code) + + # 转换日期格式(Tushare 要求 YYYYMMDD) + ts_start = start_date.replace('-', '') + ts_end = end_date.replace('-', '') + + logger.debug(f"调用 Tushare daily({ts_code}, {ts_start}, {ts_end})") + + try: + # 调用 daily 接口获取日线数据 + df = self._api.daily( + ts_code=ts_code, + start_date=ts_start, + end_date=ts_end, + ) + + return df + + except Exception as e: + error_msg = str(e).lower() + + # 检测配额超限 + if any(keyword in error_msg for keyword in ['quota', '配额', 'limit', '权限']): + logger.warning(f"Tushare 配额可能超限: {e}") + raise RateLimitError(f"Tushare 配额超限: {e}") from e + + raise DataFetchError(f"Tushare 获取数据失败: {e}") from e + + def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame: + """ + 标准化 Tushare 数据 + + Tushare daily 返回的列名: + ts_code, trade_date, open, high, low, close, pre_close, change, pct_chg, vol, amount + + 需要映射到标准列名: + date, open, high, low, close, volume, amount, pct_chg + """ + df = df.copy() + + # 列名映射 + column_mapping = { + 'trade_date': 'date', + 'vol': 'volume', + # open, high, low, close, amount, pct_chg 列名相同 + } + + df = df.rename(columns=column_mapping) + + # 转换日期格式(YYYYMMDD -> YYYY-MM-DD) + if 'date' in df.columns: + df['date'] = pd.to_datetime(df['date'], format='%Y%m%d') + + # 成交量单位转换(Tushare 的 vol 单位是手,需要转换为股) + if 'volume' in df.columns: + df['volume'] = df['volume'] * 100 + + # 成交额单位转换(Tushare 的 amount 单位是千元,转换为元) + if 'amount' in df.columns: + df['amount'] = df['amount'] * 1000 + + # 添加股票代码列 + df['code'] = stock_code + + # 只保留需要的列 + keep_cols = ['code'] + STANDARD_COLUMNS + existing_cols = [col for col in keep_cols if col in df.columns] + df = df[existing_cols] + + return df + + +if __name__ == "__main__": + # 测试代码 + logging.basicConfig(level=logging.DEBUG) + + fetcher = TushareFetcher() + + try: + df = fetcher.get_daily_data('600519') # 茅台 + print(f"获取成功,共 {len(df)} 条数据") + print(df.tail()) + except Exception as e: + print(f"获取失败: {e}") diff --git a/data_provider/yfinance_fetcher.py b/data_provider/yfinance_fetcher.py new file mode 100644 index 000000000..88ec5859d --- /dev/null +++ b/data_provider/yfinance_fetcher.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +""" +=================================== +YfinanceFetcher - 兜底数据源 (Priority 4) +=================================== + +数据来源:Yahoo Finance(通过 yfinance 库) +特点:国际数据源、可能有延迟或缺失 +定位:当所有国内数据源都失败时的最后保障 + +关键策略: +1. 自动将 A 股代码转换为 yfinance 格式(.SS / .SZ) +2. 处理 Yahoo Finance 的数据格式差异 +3. 失败后指数退避重试 +""" + +import logging +from datetime import datetime +from typing import Optional + +import pandas as pd +from tenacity import ( + retry, + stop_after_attempt, + wait_exponential, + retry_if_exception_type, + before_sleep_log, +) + +from .base import BaseFetcher, DataFetchError, STANDARD_COLUMNS + +logger = logging.getLogger(__name__) + + +class YfinanceFetcher(BaseFetcher): + """ + Yahoo Finance 数据源实现 + + 优先级:4(最低,作为兜底) + 数据来源:Yahoo Finance + + 关键策略: + - 自动转换股票代码格式 + - 处理时区和数据格式差异 + - 失败后指数退避重试 + + 注意事项: + - A 股数据可能有延迟 + - 某些股票可能无数据 + - 数据精度可能与国内源略有差异 + """ + + name = "YfinanceFetcher" + priority = 4 + + def __init__(self): + """初始化 YfinanceFetcher""" + pass + + def _convert_stock_code(self, stock_code: str) -> str: + """ + 转换股票代码为 Yahoo Finance 格式 + + Yahoo Finance A 股代码格式: + - 沪市:600519.SS (Shanghai Stock Exchange) + - 深市:000001.SZ (Shenzhen Stock Exchange) + + Args: + stock_code: 原始代码,如 '600519', '000001' + + Returns: + Yahoo Finance 格式代码,如 '600519.SS', '000001.SZ' + """ + code = stock_code.strip() + + # 已经包含后缀的情况 + if '.SS' in code.upper() or '.SZ' in code.upper(): + return code.upper() + + # 去除可能的后缀 + code = code.replace('.SH', '').replace('.sh', '') + + # 根据代码前缀判断市场 + if code.startswith(('600', '601', '603', '688')): + return f"{code}.SS" + elif code.startswith(('000', '002', '300')): + return f"{code}.SZ" + else: + logger.warning(f"无法确定股票 {code} 的市场,默认使用深市") + return f"{code}.SZ" + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=2, max=30), + retry=retry_if_exception_type((ConnectionError, TimeoutError)), + before_sleep=before_sleep_log(logger, logging.WARNING), + ) + def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame: + """ + 从 Yahoo Finance 获取原始数据 + + 使用 yfinance.download() 获取历史数据 + + 流程: + 1. 转换股票代码格式 + 2. 调用 yfinance API + 3. 处理返回数据 + """ + import yfinance as yf + + # 转换代码格式 + yf_code = self._convert_stock_code(stock_code) + + logger.debug(f"调用 yfinance.download({yf_code}, {start_date}, {end_date})") + + try: + # 使用 yfinance 下载数据 + df = yf.download( + tickers=yf_code, + start=start_date, + end=end_date, + progress=False, # 禁止进度条 + auto_adjust=True, # 自动调整价格(复权) + ) + + if df.empty: + raise DataFetchError(f"Yahoo Finance 未查询到 {stock_code} 的数据") + + return df + + except Exception as e: + if isinstance(e, DataFetchError): + raise + raise DataFetchError(f"Yahoo Finance 获取数据失败: {e}") from e + + def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame: + """ + 标准化 Yahoo Finance 数据 + + yfinance 返回的列名: + Open, High, Low, Close, Volume(索引是日期) + + 需要映射到标准列名: + date, open, high, low, close, volume, amount, pct_chg + """ + df = df.copy() + + # 重置索引,将日期从索引变为列 + df = df.reset_index() + + # 列名映射(yfinance 使用首字母大写) + column_mapping = { + 'Date': 'date', + 'Open': 'open', + 'High': 'high', + 'Low': 'low', + 'Close': 'close', + 'Volume': 'volume', + } + + df = df.rename(columns=column_mapping) + + # 计算涨跌幅(因为 yfinance 不直接提供) + if 'close' in df.columns: + df['pct_chg'] = df['close'].pct_change() * 100 + df['pct_chg'] = df['pct_chg'].fillna(0).round(2) + + # 计算成交额(yfinance 不提供,使用估算值) + # 成交额 ≈ 成交量 * 平均价格 + if 'volume' in df.columns and 'close' in df.columns: + df['amount'] = df['volume'] * df['close'] + else: + df['amount'] = 0 + + # 添加股票代码列 + df['code'] = stock_code + + # 只保留需要的列 + keep_cols = ['code'] + STANDARD_COLUMNS + existing_cols = [col for col in keep_cols if col in df.columns] + df = df[existing_cols] + + return df + + +if __name__ == "__main__": + # 测试代码 + logging.basicConfig(level=logging.DEBUG) + + fetcher = YfinanceFetcher() + + try: + df = fetcher.get_daily_data('600519') # 茅台 + print(f"获取成功,共 {len(df)} 条数据") + print(df.tail()) + except Exception as e: + print(f"获取失败: {e}") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..07945b361 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +# =================================== +# A股自选股智能分析系统 - Docker Compose +# =================================== +# 一键启动,自动管理容器生命周期 + +version: '3.8' + +services: + stock-analyzer: + build: + context: . + dockerfile: Dockerfile + container_name: stock-analyzer + restart: unless-stopped + + # 环境变量(从 .env 文件加载) + env_file: + - .env + + # 挂载卷(持久化数据) + volumes: + - ./data:/app/data # 数据库 + - ./logs:/app/logs # 日志文件 + - ./reports:/app/reports # 分析报告 + + # 网络设置(如果需要代理,取消注释下面的配置) + # network_mode: "host" + # 或使用环境变量设置代理 + environment: + - TZ=Asia/Shanghai + # 代理设置(如果需要) + # - http_proxy=http://host.docker.internal:10809 + # - https_proxy=http://host.docker.internal:10809 + + # 日志配置 + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # 资源限制 + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + +# 如果需要网络隔离,可以定义网络 +# networks: +# stock-net: +# driver: bridge diff --git a/main.py b/main.py new file mode 100644 index 000000000..37b70b3b6 --- /dev/null +++ b/main.py @@ -0,0 +1,845 @@ +# -*- 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 +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import List, Dict, Any, Optional, Tuple + +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, send_daily_report +from search_service import SearchService, SearchResponse +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 + ): + """ + 初始化调度器 + + Args: + config: 配置对象(可选,默认使用全局配置) + max_workers: 最大并发线程数(可选,默认从配置读取) + """ + self.config = config or get_config() + self.max_workers = max_workers or self.config.max_workers + + # 初始化各模块 + self.db = get_db() + self.fetcher_manager = DataFetcherManager() + self.akshare_fetcher = AkshareFetcher() # 用于获取增强数据(量比、筹码等) + self.trend_analyzer = StockTrendAnalyzer() # 趋势分析器 + self.analyzer = GeminiAnalyzer() + self.notifier = NotificationService() + + # 初始化搜索服务 + self.search_service = SearchService( + 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 + ) -> Optional[AnalysisResult]: + """ + 处理单只股票的完整流程 + + 包括: + 1. 获取数据 + 2. 保存数据 + 3. AI 分析 + + 此方法会被线程池调用,需要处理好异常 + + Args: + code: 股票代码 + skip_analysis: 是否跳过 AI 分析 + + 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}" + ) + + 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: + 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 '完整分析'}") + + 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 + ): code + for code in stock_codes + } + + # 收集结果 + for future in as_completed(future_to_code): + code = future_to_code[future] + try: + result = future.result() + if result: + results.append(result) + 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: + self._send_notifications(results) + + return results + + def _send_notifications(self, results: List[AnalysisResult]) -> None: + """ + 发送分析结果通知 + + 生成决策仪表盘格式的报告 + + Args: + results: 分析结果列表 + """ + try: + logger.info("生成决策仪表盘日报...") + + # 生成决策仪表盘格式的详细日报 + report = self.notifier.generate_dashboard_report(results) + + # 保存到本地 + filepath = self.notifier.save_report_to_file(report) + logger.info(f"决策仪表盘日报已保存: {filepath}") + + # 推送到企业微信(使用精简版决策仪表盘) + if self.notifier.is_available(): + # 生成精简版决策仪表盘用于微信推送 + wechat_dashboard = self.notifier.generate_wechat_dashboard(results) + logger.info(f"微信决策仪表盘长度: {len(wechat_dashboard)} 字符") + logger.debug(f"微信推送内容:\n{wechat_dashboard}") + + success = self.notifier.send_to_wechat(wechat_dashboard) + 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 --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( + '--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='跳过大盘复盘分析' + ) + + 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: + # 推送到微信 + if notifier.is_available(): + # 添加标题 + wechat_report = f"## 🎯 大盘复盘\n\n{review_report}" + if len(wechat_report) > 3800: + wechat_report = wechat_report[:3800] + "\n...(已截断)" + + success = notifier.send_to_wechat(wechat_report) + 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: + # 创建调度器 + 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 + ) + + # 2. 运行大盘复盘(如果启用且不是仅个股模式) + if config.market_review_enabled and not args.no_market_review: + run_market_review( + notifier=pipeline.notifier, + analyzer=pipeline.analyzer, + search_service=pipeline.search_service + ) + + # 输出摘要 + 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任务执行完成") + + except Exception as e: + logger.exception(f"分析流程执行失败: {e}") + + +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}") + + try: + # 模式1: 仅大盘复盘 + if args.market_review: + logger.info("模式: 仅大盘复盘") + notifier = NotificationService(config.wechat_webhook_url) + + # 初始化搜索服务和分析器(如果有配置) + search_service = None + analyzer = None + + if config.tavily_api_keys or config.serpapi_keys: + search_service = SearchService( + 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程序执行完成") + 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()) diff --git a/market_analyzer.py b/market_analyzer.py new file mode 100644 index 000000000..9f683fbef --- /dev/null +++ b/market_analyzer.py @@ -0,0 +1,520 @@ +# -*- coding: utf-8 -*- +""" +=================================== +大盘复盘分析模块 +=================================== + +职责: +1. 获取大盘指数数据(上证、深证、创业板) +2. 搜索市场新闻形成复盘情报 +3. 使用大模型生成每日大盘复盘报告 +""" + +import logging +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional, Dict, Any, List + +import akshare as ak +import pandas as pd + +from config import get_config +from search_service import SearchService + +logger = logging.getLogger(__name__) + + +@dataclass +class MarketIndex: + """大盘指数数据""" + code: str # 指数代码 + name: str # 指数名称 + current: float = 0.0 # 当前点位 + change: float = 0.0 # 涨跌点数 + change_pct: float = 0.0 # 涨跌幅(%) + open: float = 0.0 # 开盘点位 + high: float = 0.0 # 最高点位 + low: float = 0.0 # 最低点位 + prev_close: float = 0.0 # 昨收点位 + volume: float = 0.0 # 成交量(手) + amount: float = 0.0 # 成交额(元) + amplitude: float = 0.0 # 振幅(%) + + def to_dict(self) -> Dict[str, Any]: + return { + 'code': self.code, + 'name': self.name, + 'current': self.current, + 'change': self.change, + 'change_pct': self.change_pct, + 'open': self.open, + 'high': self.high, + 'low': self.low, + 'volume': self.volume, + 'amount': self.amount, + 'amplitude': self.amplitude, + } + + +@dataclass +class MarketOverview: + """市场概览数据""" + date: str # 日期 + indices: List[MarketIndex] = field(default_factory=list) # 主要指数 + up_count: int = 0 # 上涨家数 + down_count: int = 0 # 下跌家数 + flat_count: int = 0 # 平盘家数 + limit_up_count: int = 0 # 涨停家数 + limit_down_count: int = 0 # 跌停家数 + total_amount: float = 0.0 # 两市成交额(亿元) + north_flow: float = 0.0 # 北向资金净流入(亿元) + + # 板块涨幅榜 + top_sectors: List[Dict] = field(default_factory=list) # 涨幅前5板块 + bottom_sectors: List[Dict] = field(default_factory=list) # 跌幅前5板块 + + +class MarketAnalyzer: + """ + 大盘复盘分析器 + + 功能: + 1. 获取大盘指数实时行情 + 2. 获取市场涨跌统计 + 3. 获取板块涨跌榜 + 4. 搜索市场新闻 + 5. 生成大盘复盘报告 + """ + + # 主要指数代码 + MAIN_INDICES = { + '000001': '上证指数', + '399001': '深证成指', + '399006': '创业板指', + '000688': '科创50', + '000016': '上证50', + '000300': '沪深300', + } + + def __init__(self, search_service: Optional[SearchService] = None, analyzer=None): + """ + 初始化大盘分析器 + + Args: + search_service: 搜索服务实例 + analyzer: AI分析器实例(用于调用LLM) + """ + self.config = get_config() + self.search_service = search_service + self.analyzer = analyzer + + def get_market_overview(self) -> MarketOverview: + """ + 获取市场概览数据 + + Returns: + MarketOverview: 市场概览数据对象 + """ + today = datetime.now().strftime('%Y-%m-%d') + overview = MarketOverview(date=today) + + # 1. 获取主要指数行情 + overview.indices = self._get_main_indices() + + # 2. 获取涨跌统计 + self._get_market_statistics(overview) + + # 3. 获取板块涨跌榜 + self._get_sector_rankings(overview) + + # 4. 获取北向资金(可选) + self._get_north_flow(overview) + + return overview + + def _get_main_indices(self) -> List[MarketIndex]: + """获取主要指数实时行情""" + indices = [] + + try: + logger.info("[大盘] 获取主要指数实时行情...") + + # 使用 akshare 获取指数行情 + df = ak.stock_zh_index_spot_em() + + if df is not None and not df.empty: + for code, name in self.MAIN_INDICES.items(): + # 查找对应指数 + row = df[df['代码'] == code] + if row.empty: + # 尝试带前缀查找 + row = df[df['代码'].str.contains(code)] + + if not row.empty: + row = row.iloc[0] + index = MarketIndex( + code=code, + name=name, + current=float(row.get('最新价', 0) or 0), + change=float(row.get('涨跌额', 0) or 0), + change_pct=float(row.get('涨跌幅', 0) or 0), + open=float(row.get('今开', 0) or 0), + high=float(row.get('最高', 0) or 0), + low=float(row.get('最低', 0) or 0), + prev_close=float(row.get('昨收', 0) or 0), + volume=float(row.get('成交量', 0) or 0), + amount=float(row.get('成交额', 0) or 0), + ) + # 计算振幅 + if index.prev_close > 0: + index.amplitude = (index.high - index.low) / index.prev_close * 100 + indices.append(index) + + logger.info(f"[大盘] 获取到 {len(indices)} 个指数行情") + + except Exception as e: + logger.error(f"[大盘] 获取指数行情失败: {e}") + + return indices + + def _get_market_statistics(self, overview: MarketOverview): + """获取市场涨跌统计""" + try: + logger.info("[大盘] 获取市场涨跌统计...") + + # 获取全部A股实时行情 + df = ak.stock_zh_a_spot_em() + + if df is not None and not df.empty: + # 涨跌统计 + change_col = '涨跌幅' + if change_col in df.columns: + df[change_col] = pd.to_numeric(df[change_col], errors='coerce') + overview.up_count = len(df[df[change_col] > 0]) + overview.down_count = len(df[df[change_col] < 0]) + overview.flat_count = len(df[df[change_col] == 0]) + + # 涨停跌停统计(涨跌幅 >= 9.9% 或 <= -9.9%) + overview.limit_up_count = len(df[df[change_col] >= 9.9]) + overview.limit_down_count = len(df[df[change_col] <= -9.9]) + + # 两市成交额 + amount_col = '成交额' + if amount_col in df.columns: + df[amount_col] = pd.to_numeric(df[amount_col], errors='coerce') + overview.total_amount = df[amount_col].sum() / 1e8 # 转为亿元 + + logger.info(f"[大盘] 涨:{overview.up_count} 跌:{overview.down_count} 平:{overview.flat_count} " + f"涨停:{overview.limit_up_count} 跌停:{overview.limit_down_count} " + f"成交额:{overview.total_amount:.0f}亿") + + except Exception as e: + logger.error(f"[大盘] 获取涨跌统计失败: {e}") + + def _get_sector_rankings(self, overview: MarketOverview): + """获取板块涨跌榜""" + try: + logger.info("[大盘] 获取板块涨跌榜...") + + # 获取行业板块行情 + df = ak.stock_board_industry_name_em() + + if df is not None and not df.empty: + change_col = '涨跌幅' + if change_col in df.columns: + df[change_col] = pd.to_numeric(df[change_col], errors='coerce') + df = df.dropna(subset=[change_col]) + + # 涨幅前5 + top = df.nlargest(5, change_col) + overview.top_sectors = [ + {'name': row['板块名称'], 'change_pct': row[change_col]} + for _, row in top.iterrows() + ] + + # 跌幅前5 + bottom = df.nsmallest(5, change_col) + overview.bottom_sectors = [ + {'name': row['板块名称'], 'change_pct': row[change_col]} + for _, row in bottom.iterrows() + ] + + logger.info(f"[大盘] 领涨板块: {[s['name'] for s in overview.top_sectors]}") + logger.info(f"[大盘] 领跌板块: {[s['name'] for s in overview.bottom_sectors]}") + + except Exception as e: + logger.error(f"[大盘] 获取板块涨跌榜失败: {e}") + + def _get_north_flow(self, overview: MarketOverview): + """获取北向资金流入""" + try: + logger.info("[大盘] 获取北向资金...") + + # 获取北向资金数据 + df = ak.stock_hsgt_north_net_flow_in_em(symbol="北上") + + if df is not None and not df.empty: + # 取最新一条数据 + latest = df.iloc[-1] + if '当日净流入' in df.columns: + overview.north_flow = float(latest['当日净流入']) / 1e8 # 转为亿元 + elif '净流入' in df.columns: + overview.north_flow = float(latest['净流入']) / 1e8 + + logger.info(f"[大盘] 北向资金净流入: {overview.north_flow:.2f}亿") + + except Exception as e: + logger.warning(f"[大盘] 获取北向资金失败: {e}") + + def search_market_news(self) -> List[Dict]: + """ + 搜索市场新闻 + + Returns: + 新闻列表 + """ + if not self.search_service: + logger.warning("[大盘] 搜索服务未配置,跳过新闻搜索") + return [] + + all_news = [] + today = datetime.now() + month_str = f"{today.year}年{today.month}月" + + # 多维度搜索 + search_queries = [ + f"A股 大盘 复盘 {month_str}", + f"股市 行情 分析 今日 {month_str}", + f"A股 市场 热点 板块 {month_str}", + ] + + try: + logger.info("[大盘] 开始搜索市场新闻...") + + for query in search_queries: + # 使用 search_stock_news 方法,传入"大盘"作为股票名 + response = self.search_service.search_stock_news( + stock_code="market", + stock_name="大盘", + max_results=3, + focus_keywords=query.split() + ) + if response and response.results: + all_news.extend(response.results) + logger.info(f"[大盘] 搜索 '{query}' 获取 {len(response.results)} 条结果") + + logger.info(f"[大盘] 共获取 {len(all_news)} 条市场新闻") + + except Exception as e: + logger.error(f"[大盘] 搜索市场新闻失败: {e}") + + return all_news + + def generate_market_review(self, overview: MarketOverview, news: List) -> str: + """ + 使用大模型生成大盘复盘报告 + + Args: + overview: 市场概览数据 + news: 市场新闻列表 (SearchResult 对象列表) + + Returns: + 大盘复盘报告文本 + """ + if not self.analyzer: + logger.warning("[大盘] AI分析器未配置,使用模板生成报告") + return self._generate_template_review(overview, news) + + # 构建 Prompt + prompt = self._build_review_prompt(overview, news) + + try: + logger.info("[大盘] 调用大模型生成复盘报告...") + + # 使用 analyzer 的内部模型直接调用 + response = self.analyzer._model.generate_content( + prompt, + generation_config={ + 'temperature': 0.7, + 'max_output_tokens': 2048, + } + ) + + if response and response.text: + review = response.text.strip() + logger.info(f"[大盘] 复盘报告生成成功,长度: {len(review)} 字符") + return review + else: + logger.warning("[大盘] 大模型返回为空") + return self._generate_template_review(overview, news) + + except Exception as e: + logger.error(f"[大盘] 大模型生成复盘报告失败: {e}") + return self._generate_template_review(overview, news) + + def _build_review_prompt(self, overview: MarketOverview, news: List) -> str: + """构建复盘报告 Prompt""" + # 指数行情信息 + indices_text = "" + for idx in overview.indices: + emoji = "🔴" if idx.change_pct < 0 else "🟢" if idx.change_pct > 0 else "⚪" + indices_text += f"- {idx.name}: {idx.current:.2f} ({emoji}{idx.change_pct:+.2f}%)\n" + + # 板块信息 + top_sectors_text = ", ".join([f"{s['name']}({s['change_pct']:+.2f}%)" for s in overview.top_sectors[:3]]) + bottom_sectors_text = ", ".join([f"{s['name']}({s['change_pct']:+.2f}%)" for s in overview.bottom_sectors[:3]]) + + # 新闻信息 - 支持 SearchResult 对象或字典 + news_text = "" + for i, n in enumerate(news[:6], 1): + # 兼容 SearchResult 对象和字典 + if hasattr(n, 'title'): + title = n.title[:50] if n.title else '' + snippet = n.snippet[:100] if n.snippet else '' + else: + title = n.get('title', '')[:50] + snippet = n.get('snippet', '')[:100] + news_text += f"{i}. {title}\n {snippet}\n" + + prompt = f"""# 大盘复盘分析请求 + +## 📅 日期 +{overview.date} + +## 📊 主要指数 +{indices_text} + +## 📈 市场概况 +- 上涨: {overview.up_count} 家 | 下跌: {overview.down_count} 家 | 平盘: {overview.flat_count} 家 +- 涨停: {overview.limit_up_count} 家 | 跌停: {overview.limit_down_count} 家 +- 两市成交额: {overview.total_amount:.0f} 亿元 +- 北向资金: {overview.north_flow:+.2f} 亿元 + +## 🔥 板块表现 +**领涨板块**: {top_sectors_text} +**领跌板块**: {bottom_sectors_text} + +## 📰 市场新闻 +{news_text if news_text else "暂无相关新闻"} + +--- + +请根据以上数据,生成一份专业的大盘复盘报告。要求: + +1. **市场总结**(2-3句话概括今日市场表现) +2. **指数点评**(分析各指数走势特点) +3. **资金动向**(分析成交额和北向资金含义) +4. **热点解读**(分析领涨领跌板块背后的逻辑) +5. **后市展望**(结合新闻给出明日市场预判) +6. **风险提示**(需要关注的风险点) + +请用简洁专业的语言,输出 Markdown 格式,适合在微信阅读。 +""" + return prompt + + def _generate_template_review(self, overview: MarketOverview, news: List) -> str: + """使用模板生成复盘报告(无大模型时的备选方案)""" + + # 判断市场走势 + sh_index = next((idx for idx in overview.indices if idx.code == '000001'), None) + if sh_index: + if sh_index.change_pct > 1: + market_mood = "强势上涨 📈" + elif sh_index.change_pct > 0: + market_mood = "小幅上涨 🔼" + elif sh_index.change_pct > -1: + market_mood = "小幅下跌 🔽" + else: + market_mood = "明显下跌 📉" + else: + market_mood = "震荡整理 ↔️" + + # 指数行情 + indices_text = "" + for idx in overview.indices[:4]: + emoji = "🔴" if idx.change_pct < 0 else "🟢" if idx.change_pct > 0 else "⚪" + indices_text += f"- **{idx.name}**: {idx.current:.2f} ({emoji}{idx.change_pct:+.2f}%)\n" + + # 板块信息 + top_text = "、".join([s['name'] for s in overview.top_sectors[:3]]) + bottom_text = "、".join([s['name'] for s in overview.bottom_sectors[:3]]) + + report = f"""## 🎯 {overview.date} 大盘复盘 + +### 📊 市场总结 +今日A股市场整体呈现**{market_mood}**态势。 + +### 📈 主要指数 +{indices_text} + +### 📉 涨跌统计 +| 指标 | 数值 | +|------|------| +| 上涨家数 | {overview.up_count} | +| 下跌家数 | {overview.down_count} | +| 涨停 | {overview.limit_up_count} | +| 跌停 | {overview.limit_down_count} | +| 两市成交额 | {overview.total_amount:.0f}亿 | +| 北向资金 | {overview.north_flow:+.2f}亿 | + +### 🔥 板块表现 +- **领涨**: {top_text} +- **领跌**: {bottom_text} + +### ⚠️ 风险提示 +市场有风险,投资需谨慎。以上数据仅供参考,不构成投资建议。 + +--- +*复盘时间: {datetime.now().strftime('%H:%M')}* +""" + return report + + def run_daily_review(self) -> str: + """ + 执行每日大盘复盘流程 + + Returns: + 复盘报告文本 + """ + logger.info("========== 开始大盘复盘分析 ==========") + + # 1. 获取市场概览 + overview = self.get_market_overview() + + # 2. 搜索市场新闻 + news = self.search_market_news() + + # 3. 生成复盘报告 + report = self.generate_market_review(overview, news) + + logger.info("========== 大盘复盘分析完成 ==========") + + return report + + +# 测试入口 +if __name__ == "__main__": + import sys + sys.path.insert(0, '.') + + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s', + ) + + analyzer = MarketAnalyzer() + + # 测试获取市场概览 + overview = analyzer.get_market_overview() + print(f"\n=== 市场概览 ===") + print(f"日期: {overview.date}") + print(f"指数数量: {len(overview.indices)}") + for idx in overview.indices: + print(f" {idx.name}: {idx.current:.2f} ({idx.change_pct:+.2f}%)") + print(f"上涨: {overview.up_count} | 下跌: {overview.down_count}") + print(f"成交额: {overview.total_amount:.0f}亿") + + # 测试生成模板报告 + report = analyzer._generate_template_review(overview, []) + print(f"\n=== 复盘报告 ===") + print(report) diff --git a/notification.py b/notification.py new file mode 100644 index 000000000..49c453294 --- /dev/null +++ b/notification.py @@ -0,0 +1,1010 @@ +# -*- coding: utf-8 -*- +""" +=================================== +A股自选股智能分析系统 - 通知层 +=================================== + +职责: +1. 汇总分析结果生成日报 +2. 支持 Markdown 格式输出 +3. 推送到企业微信 Webhook +""" + +import logging +from datetime import datetime +from typing import List, Dict, Any, Optional + +import requests + +from config import get_config +from analyzer import AnalysisResult + +logger = logging.getLogger(__name__) + + +class NotificationService: + """ + 通知服务 + + 职责: + 1. 生成 Markdown 格式的分析日报 + 2. 推送消息到企业微信机器人 + 3. 支持本地保存日报 + """ + + def __init__(self, webhook_url: Optional[str] = None): + """ + 初始化通知服务 + + Args: + webhook_url: 企业微信 Webhook URL(可选,默认从配置读取) + """ + self._webhook_url = webhook_url or get_config().wechat_webhook_url + + if not self._webhook_url: + logger.warning("企业微信 Webhook URL 未配置,将不发送推送通知") + + def is_available(self) -> bool: + """检查通知服务是否可用""" + return bool(self._webhook_url) + + def generate_daily_report( + self, + results: List[AnalysisResult], + report_date: Optional[str] = None + ) -> str: + """ + 生成 Markdown 格式的日报(详细版) + + Args: + results: 分析结果列表 + report_date: 报告日期(默认今天) + + Returns: + Markdown 格式的日报内容 + """ + if report_date is None: + report_date = datetime.now().strftime('%Y-%m-%d') + + # 标题 + report_lines = [ + f"# 📅 {report_date} A股自选股智能分析报告", + "", + f"> 共分析 **{len(results)}** 只股票 | 报告生成时间:{datetime.now().strftime('%H:%M:%S')}", + "", + "---", + "", + ] + + # 按评分排序(高分在前) + sorted_results = sorted( + results, + key=lambda x: x.sentiment_score, + reverse=True + ) + + # 统计信息 + buy_count = sum(1 for r in results if r.operation_advice in ['买入', '加仓', '强烈买入']) + sell_count = sum(1 for r in results if r.operation_advice in ['卖出', '减仓', '强烈卖出']) + hold_count = sum(1 for r in results if r.operation_advice in ['持有', '观望']) + avg_score = sum(r.sentiment_score for r in results) / len(results) if results else 0 + + report_lines.extend([ + "## 📊 操作建议汇总", + "", + f"| 指标 | 数值 |", + f"|------|------|", + f"| 🟢 建议买入/加仓 | **{buy_count}** 只 |", + f"| 🟡 建议持有/观望 | **{hold_count}** 只 |", + f"| 🔴 建议减仓/卖出 | **{sell_count}** 只 |", + f"| 📈 平均看多评分 | **{avg_score:.1f}** 分 |", + "", + "---", + "", + "## 📈 个股详细分析", + "", + ]) + + # 逐个股票的详细分析 + for result in sorted_results: + emoji = result.get_emoji() + confidence_stars = result.get_confidence_stars() if hasattr(result, 'get_confidence_stars') else '⭐⭐' + + report_lines.extend([ + f"### {emoji} {result.name} ({result.code})", + "", + f"**操作建议:{result.operation_advice}** | **综合评分:{result.sentiment_score}分** | **趋势预测:{result.trend_prediction}** | **置信度:{confidence_stars}**", + "", + ]) + + # 核心看点 + if hasattr(result, 'key_points') and result.key_points: + report_lines.extend([ + f"**🎯 核心看点**:{result.key_points}", + "", + ]) + + # 买入/卖出理由 + if hasattr(result, 'buy_reason') and result.buy_reason: + report_lines.extend([ + f"**💡 操作理由**:{result.buy_reason}", + "", + ]) + + # 走势分析 + if hasattr(result, 'trend_analysis') and result.trend_analysis: + report_lines.extend([ + "#### 📉 走势分析", + f"{result.trend_analysis}", + "", + ]) + + # 短期/中期展望 + outlook_lines = [] + if hasattr(result, 'short_term_outlook') and result.short_term_outlook: + outlook_lines.append(f"- **短期(1-3日)**:{result.short_term_outlook}") + if hasattr(result, 'medium_term_outlook') and result.medium_term_outlook: + outlook_lines.append(f"- **中期(1-2周)**:{result.medium_term_outlook}") + if outlook_lines: + report_lines.extend([ + "#### 🔮 市场展望", + *outlook_lines, + "", + ]) + + # 技术面分析 + tech_lines = [] + if result.technical_analysis: + tech_lines.append(f"**综合**:{result.technical_analysis}") + if hasattr(result, 'ma_analysis') and result.ma_analysis: + tech_lines.append(f"**均线**:{result.ma_analysis}") + if hasattr(result, 'volume_analysis') and result.volume_analysis: + tech_lines.append(f"**量能**:{result.volume_analysis}") + if hasattr(result, 'pattern_analysis') and result.pattern_analysis: + tech_lines.append(f"**形态**:{result.pattern_analysis}") + if tech_lines: + report_lines.extend([ + "#### 📊 技术面分析", + *tech_lines, + "", + ]) + + # 基本面分析 + fund_lines = [] + if hasattr(result, 'fundamental_analysis') and result.fundamental_analysis: + fund_lines.append(result.fundamental_analysis) + if hasattr(result, 'sector_position') and result.sector_position: + fund_lines.append(f"**板块地位**:{result.sector_position}") + if hasattr(result, 'company_highlights') and result.company_highlights: + fund_lines.append(f"**公司亮点**:{result.company_highlights}") + if fund_lines: + report_lines.extend([ + "#### 🏢 基本面分析", + *fund_lines, + "", + ]) + + # 消息面/情绪面 + news_lines = [] + if result.news_summary: + news_lines.append(f"**新闻摘要**:{result.news_summary}") + if hasattr(result, 'market_sentiment') and result.market_sentiment: + news_lines.append(f"**市场情绪**:{result.market_sentiment}") + if hasattr(result, 'hot_topics') and result.hot_topics: + news_lines.append(f"**相关热点**:{result.hot_topics}") + if news_lines: + report_lines.extend([ + "#### 📰 消息面/情绪面", + *news_lines, + "", + ]) + + # 综合分析 + if result.analysis_summary: + report_lines.extend([ + "#### 📝 综合分析", + result.analysis_summary, + "", + ]) + + # 风险提示 + if hasattr(result, 'risk_warning') and result.risk_warning: + report_lines.extend([ + f"⚠️ **风险提示**:{result.risk_warning}", + "", + ]) + + # 数据来源说明 + if hasattr(result, 'search_performed') and result.search_performed: + report_lines.append(f"*🔍 已执行联网搜索*") + if hasattr(result, 'data_sources') and result.data_sources: + report_lines.append(f"*📋 数据来源:{result.data_sources}*") + + # 错误信息(如果有) + if not result.success and result.error_message: + report_lines.extend([ + "", + f"❌ **分析异常**:{result.error_message[:100]}", + ]) + + report_lines.extend([ + "", + "---", + "", + ]) + + # 底部信息(去除免责声明) + report_lines.extend([ + "", + f"*报告生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*", + ]) + + return "\n".join(report_lines) + + def _get_signal_level(self, result: AnalysisResult) -> tuple: + """ + 根据操作建议获取信号等级和颜色 + + Returns: + (信号文字, emoji, 颜色标记) + """ + advice = result.operation_advice + score = result.sentiment_score + + if advice in ['强烈买入'] or score >= 80: + return ('强烈买入', '💚', '强买') + elif advice in ['买入', '加仓'] or score >= 65: + return ('买入', '🟢', '买入') + elif advice in ['持有'] or 55 <= score < 65: + return ('持有', '🟡', '持有') + elif advice in ['观望'] or 45 <= score < 55: + return ('观望', '⚪', '观望') + elif advice in ['减仓'] or 35 <= score < 45: + return ('减仓', '🟠', '减仓') + elif advice in ['卖出', '强烈卖出'] or score < 35: + return ('卖出', '🔴', '卖出') + else: + return ('观望', '⚪', '观望') + + def generate_dashboard_report( + self, + results: List[AnalysisResult], + report_date: Optional[str] = None + ) -> str: + """ + 生成决策仪表盘格式的日报(详细版) + + 格式:市场概览 + 重要信息 + 核心结论 + 数据透视 + 作战计划 + + Args: + results: 分析结果列表 + report_date: 报告日期(默认今天) + + Returns: + Markdown 格式的决策仪表盘日报 + """ + if report_date is None: + report_date = datetime.now().strftime('%Y-%m-%d') + + # 按评分排序(高分在前) + sorted_results = sorted(results, key=lambda x: x.sentiment_score, reverse=True) + + # 统计信息 + buy_count = sum(1 for r in results if r.operation_advice in ['买入', '加仓', '强烈买入']) + sell_count = sum(1 for r in results if r.operation_advice in ['卖出', '减仓', '强烈卖出']) + hold_count = sum(1 for r in results if r.operation_advice in ['持有', '观望']) + + report_lines = [ + f"# 🎯 {report_date} 决策仪表盘", + "", + f"> 共分析 **{len(results)}** 只股票 | 🟢买入:{buy_count} 🟡观望:{hold_count} 🔴卖出:{sell_count}", + "", + "---", + "", + ] + + # 逐个股票的决策仪表盘 + for result in sorted_results: + signal_text, signal_emoji, signal_tag = self._get_signal_level(result) + dashboard = result.dashboard if hasattr(result, 'dashboard') and result.dashboard else {} + + # 股票名称(优先使用 dashboard 或 result 中的名称) + stock_name = result.name if result.name and not result.name.startswith('股票') else f'股票{result.code}' + + report_lines.extend([ + f"## {signal_emoji} {stock_name} ({result.code})", + "", + ]) + + # ========== 舆情与基本面概览(放在最前面)========== + intel = dashboard.get('intelligence', {}) if dashboard else {} + if intel: + report_lines.extend([ + "### 📰 重要信息速览", + "", + ]) + + # 舆情情绪总结 + if intel.get('sentiment_summary'): + report_lines.append(f"**💭 舆情情绪**: {intel['sentiment_summary']}") + + # 业绩预期 + if intel.get('earnings_outlook'): + report_lines.append(f"**📊 业绩预期**: {intel['earnings_outlook']}") + + # 风险警报(醒目显示) + risk_alerts = intel.get('risk_alerts', []) + if risk_alerts: + report_lines.append("") + report_lines.append("**🚨 风险警报**:") + for alert in risk_alerts: + report_lines.append(f"- {alert}") + + # 利好催化 + catalysts = intel.get('positive_catalysts', []) + if catalysts: + report_lines.append("") + report_lines.append("**✨ 利好催化**:") + for cat in catalysts: + report_lines.append(f"- {cat}") + + # 最新消息 + if intel.get('latest_news'): + report_lines.append("") + report_lines.append(f"**📢 最新动态**: {intel['latest_news']}") + + report_lines.append("") + + # ========== 核心结论 ========== + core = dashboard.get('core_conclusion', {}) if dashboard else {} + one_sentence = core.get('one_sentence', result.analysis_summary) + time_sense = core.get('time_sensitivity', '本周内') + pos_advice = core.get('position_advice', {}) + + report_lines.extend([ + "### 📌 核心结论", + "", + f"**{signal_emoji} {signal_text}** | {result.trend_prediction}", + "", + f"> **一句话决策**: {one_sentence}", + "", + f"⏰ **时效性**: {time_sense}", + "", + ]) + + # 持仓分类建议 + if pos_advice: + report_lines.extend([ + "| 持仓情况 | 操作建议 |", + "|---------|---------|", + f"| 🆕 **空仓者** | {pos_advice.get('no_position', result.operation_advice)} |", + f"| 💼 **持仓者** | {pos_advice.get('has_position', '继续持有')} |", + "", + ]) + + # ========== 数据透视 ========== + data_persp = dashboard.get('data_perspective', {}) if dashboard else {} + if data_persp: + trend_data = data_persp.get('trend_status', {}) + price_data = data_persp.get('price_position', {}) + vol_data = data_persp.get('volume_analysis', {}) + chip_data = data_persp.get('chip_structure', {}) + + report_lines.extend([ + "### 📊 数据透视", + "", + ]) + + # 趋势状态 + if trend_data: + is_bullish = "✅ 是" if trend_data.get('is_bullish', False) else "❌ 否" + report_lines.extend([ + f"**均线排列**: {trend_data.get('ma_alignment', 'N/A')} | 多头排列: {is_bullish} | 趋势强度: {trend_data.get('trend_score', 'N/A')}/100", + "", + ]) + + # 价格位置 + if price_data: + bias_status = price_data.get('bias_status', 'N/A') + bias_emoji = "✅" if bias_status == "安全" else ("⚠️" if bias_status == "警戒" else "🚨") + report_lines.extend([ + "| 价格指标 | 数值 |", + "|---------|------|", + f"| 当前价 | {price_data.get('current_price', 'N/A')} |", + f"| MA5 | {price_data.get('ma5', 'N/A')} |", + f"| MA10 | {price_data.get('ma10', 'N/A')} |", + f"| MA20 | {price_data.get('ma20', 'N/A')} |", + f"| 乖离率(MA5) | {price_data.get('bias_ma5', 'N/A')}% {bias_emoji}{bias_status} |", + f"| 支撑位 | {price_data.get('support_level', 'N/A')} |", + f"| 压力位 | {price_data.get('resistance_level', 'N/A')} |", + "", + ]) + + # 量能分析 + if vol_data: + report_lines.extend([ + f"**量能**: 量比 {vol_data.get('volume_ratio', 'N/A')} ({vol_data.get('volume_status', '')}) | 换手率 {vol_data.get('turnover_rate', 'N/A')}%", + f"💡 *{vol_data.get('volume_meaning', '')}*", + "", + ]) + + # 筹码结构 + if chip_data: + chip_health = chip_data.get('chip_health', 'N/A') + chip_emoji = "✅" if chip_health == "健康" else ("⚠️" if chip_health == "一般" else "🚨") + report_lines.extend([ + f"**筹码**: 获利比例 {chip_data.get('profit_ratio', 'N/A')} | 平均成本 {chip_data.get('avg_cost', 'N/A')} | 集中度 {chip_data.get('concentration', 'N/A')} {chip_emoji}{chip_health}", + "", + ]) + + # 舆情情报已移至顶部显示 + + # ========== 作战计划 ========== + battle = dashboard.get('battle_plan', {}) if dashboard else {} + if battle: + report_lines.extend([ + "### 🎯 作战计划", + "", + ]) + + # 狙击点位 + sniper = battle.get('sniper_points', {}) + if sniper: + report_lines.extend([ + "**📍 狙击点位**", + "", + "| 点位类型 | 价格 |", + "|---------|------|", + f"| 🎯 理想买入点 | {sniper.get('ideal_buy', 'N/A')} |", + f"| 🔵 次优买入点 | {sniper.get('secondary_buy', 'N/A')} |", + f"| 🛑 止损位 | {sniper.get('stop_loss', 'N/A')} |", + f"| 🎊 目标位 | {sniper.get('take_profit', 'N/A')} |", + "", + ]) + + # 仓位策略 + position = battle.get('position_strategy', {}) + if position: + report_lines.extend([ + f"**💰 仓位建议**: {position.get('suggested_position', 'N/A')}", + f"- 建仓策略: {position.get('entry_plan', 'N/A')}", + f"- 风控策略: {position.get('risk_control', 'N/A')}", + "", + ]) + + # 检查清单 + checklist = battle.get('action_checklist', []) + if checklist: + report_lines.extend([ + "**✅ 检查清单**", + "", + ]) + for item in checklist: + report_lines.append(f"- {item}") + report_lines.append("") + + # 如果没有 dashboard,显示传统格式 + if not dashboard: + # 操作理由 + if result.buy_reason: + report_lines.extend([ + f"**💡 操作理由**: {result.buy_reason}", + "", + ]) + + # 风险提示 + if result.risk_warning: + report_lines.extend([ + f"**⚠️ 风险提示**: {result.risk_warning}", + "", + ]) + + # 技术面分析 + if result.ma_analysis or result.volume_analysis: + report_lines.extend([ + "### 📊 技术面", + "", + ]) + if result.ma_analysis: + report_lines.append(f"**均线**: {result.ma_analysis}") + if result.volume_analysis: + report_lines.append(f"**量能**: {result.volume_analysis}") + report_lines.append("") + + # 消息面 + if result.news_summary: + report_lines.extend([ + "### 📰 消息面", + f"{result.news_summary}", + "", + ]) + + report_lines.extend([ + "---", + "", + ]) + + # 底部(去除免责声明) + report_lines.extend([ + "", + f"*报告生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*", + ]) + + return "\n".join(report_lines) + + def generate_wechat_dashboard(self, results: List[AnalysisResult]) -> str: + """ + 生成企业微信决策仪表盘精简版(控制在4000字符内) + + 只保留核心结论和狙击点位 + + Args: + results: 分析结果列表 + + Returns: + 精简版决策仪表盘 + """ + report_date = datetime.now().strftime('%Y-%m-%d') + + # 按评分排序 + sorted_results = sorted(results, key=lambda x: x.sentiment_score, reverse=True) + + # 统计 + buy_count = sum(1 for r in results if r.operation_advice in ['买入', '加仓', '强烈买入']) + sell_count = sum(1 for r in results if r.operation_advice in ['卖出', '减仓', '强烈卖出']) + hold_count = sum(1 for r in results if r.operation_advice in ['持有', '观望']) + + lines = [ + f"## 🎯 {report_date} 决策仪表盘", + "", + f"> {len(results)}只股票 | 🟢买入:{buy_count} 🟡观望:{hold_count} 🔴卖出:{sell_count}", + "", + ] + + for result in sorted_results: + signal_text, signal_emoji, _ = self._get_signal_level(result) + dashboard = result.dashboard if hasattr(result, 'dashboard') and result.dashboard else {} + core = dashboard.get('core_conclusion', {}) if dashboard else {} + battle = dashboard.get('battle_plan', {}) if dashboard else {} + intel = dashboard.get('intelligence', {}) if dashboard else {} + + # 股票名称 + stock_name = result.name if result.name and not result.name.startswith('股票') else f'股票{result.code}' + + # 标题行:信号等级 + 股票名称 + lines.append(f"### {signal_emoji} **{signal_text}** | {stock_name}({result.code})") + lines.append("") + + # 核心决策(一句话) + one_sentence = core.get('one_sentence', result.analysis_summary) if core else result.analysis_summary + if one_sentence: + lines.append(f"📌 **{one_sentence[:80]}**") + lines.append("") + + # 重要信息区(舆情+基本面) + info_lines = [] + + # 业绩预期 + if intel.get('earnings_outlook'): + outlook = intel['earnings_outlook'][:60] + info_lines.append(f"📊 业绩: {outlook}") + + # 舆情情绪 + if intel.get('sentiment_summary'): + sentiment = intel['sentiment_summary'][:50] + info_lines.append(f"💭 舆情: {sentiment}") + + if info_lines: + lines.extend(info_lines) + lines.append("") + + # 风险警报(最重要,醒目显示) + risks = intel.get('risk_alerts', []) if intel else [] + if risks: + lines.append("🚨 **风险**:") + for risk in risks[:2]: # 最多显示2条 + risk_text = risk[:50] + "..." if len(risk) > 50 else risk + lines.append(f" • {risk_text}") + lines.append("") + + # 利好催化 + catalysts = intel.get('positive_catalysts', []) if intel else [] + if catalysts: + lines.append("✨ **利好**:") + for cat in catalysts[:2]: # 最多显示2条 + cat_text = cat[:50] + "..." if len(cat) > 50 else cat + lines.append(f" • {cat_text}") + lines.append("") + + # 狙击点位 + sniper = battle.get('sniper_points', {}) if battle else {} + if sniper: + ideal_buy = sniper.get('ideal_buy', '') + stop_loss = sniper.get('stop_loss', '') + take_profit = sniper.get('take_profit', '') + + points = [] + if ideal_buy: + points.append(f"🎯买点:{ideal_buy[:15]}") + if stop_loss: + points.append(f"🛑止损:{stop_loss[:15]}") + if take_profit: + points.append(f"🎊目标:{take_profit[:15]}") + + if points: + lines.append(" | ".join(points)) + lines.append("") + + # 持仓建议 + pos_advice = core.get('position_advice', {}) if core else {} + if pos_advice: + no_pos = pos_advice.get('no_position', '') + has_pos = pos_advice.get('has_position', '') + if no_pos: + lines.append(f"🆕 空仓者: {no_pos[:50]}") + if has_pos: + lines.append(f"💼 持仓者: {has_pos[:50]}") + lines.append("") + + # 检查清单简化版 + checklist = battle.get('action_checklist', []) if battle else [] + if checklist: + # 只显示不通过的项目 + failed_checks = [c for c in checklist if c.startswith('❌') or c.startswith('⚠️')] + if failed_checks: + lines.append("**检查未通过项**:") + for check in failed_checks[:3]: + lines.append(f" {check[:40]}") + lines.append("") + + lines.append("---") + lines.append("") + + # 底部 + lines.append(f"*生成时间: {datetime.now().strftime('%H:%M')}*") + + content = "\n".join(lines) + + # 检查长度 + if len(content) > 3800: + logger.warning(f"仪表盘超长({len(content)}字符),截断") + content = content[:3800] + "\n...(已截断)" + + return content + + def generate_wechat_summary(self, results: List[AnalysisResult]) -> str: + """ + 生成企业微信精简版日报(控制在4000字符内) + + Args: + results: 分析结果列表 + + Returns: + 精简版 Markdown 内容 + """ + report_date = datetime.now().strftime('%Y-%m-%d') + + # 按评分排序 + sorted_results = sorted(results, key=lambda x: x.sentiment_score, reverse=True) + + # 统计 + buy_count = sum(1 for r in results if r.operation_advice in ['买入', '加仓', '强烈买入']) + sell_count = sum(1 for r in results if r.operation_advice in ['卖出', '减仓', '强烈卖出']) + hold_count = sum(1 for r in results if r.operation_advice in ['持有', '观望']) + avg_score = sum(r.sentiment_score for r in results) / len(results) if results else 0 + + lines = [ + f"## 📅 {report_date} A股分析报告", + "", + f"> 共 **{len(results)}** 只 | 🟢买入:{buy_count} 🟡持有:{hold_count} 🔴卖出:{sell_count} | 均分:{avg_score:.0f}", + "", + ] + + # 每只股票精简信息(控制长度) + for result in sorted_results: + emoji = result.get_emoji() + + # 核心信息行 + lines.append(f"### {emoji} {result.name}({result.code})") + lines.append(f"**{result.operation_advice}** | 评分:{result.sentiment_score} | {result.trend_prediction}") + + # 操作理由(截断) + if hasattr(result, 'buy_reason') and result.buy_reason: + reason = result.buy_reason[:80] + "..." if len(result.buy_reason) > 80 else result.buy_reason + lines.append(f"💡 {reason}") + + # 核心看点 + if hasattr(result, 'key_points') and result.key_points: + points = result.key_points[:60] + "..." if len(result.key_points) > 60 else result.key_points + lines.append(f"🎯 {points}") + + # 风险提示(截断) + if hasattr(result, 'risk_warning') and result.risk_warning: + risk = result.risk_warning[:50] + "..." if len(result.risk_warning) > 50 else result.risk_warning + lines.append(f"⚠️ {risk}") + + lines.append("") + + # 底部 + lines.extend([ + "---", + "*AI生成,仅供参考,不构成投资建议*", + f"*详细报告见 reports/report_{report_date.replace('-', '')}.md*" + ]) + + content = "\n".join(lines) + + # 最终检查长度 + if len(content) > 3800: + logger.warning(f"精简报告仍超长({len(content)}字符),进行截断") + content = content[:3800] + "\n\n...(内容过长已截断)" + + return content + + def send_to_wechat(self, content: str) -> bool: + """ + 推送消息到企业微信机器人 + + 企业微信 Webhook 消息格式: + { + "msgtype": "markdown", + "markdown": { + "content": "Markdown 内容" + } + } + + 注意:企业微信 Markdown 限制 4096 字符 + + Args: + content: Markdown 格式的消息内容 + + Returns: + 是否发送成功 + """ + if not self.is_available(): + logger.warning("企业微信 Webhook 未配置,跳过推送") + return False + + # 检查长度 + if len(content) > 4000: + logger.warning(f"消息内容超长({len(content)}字符),将截断至4000字符") + content = content[:3950] + "\n\n...(内容过长已截断,详见完整报告)" + + try: + return self._send_single_message(content) + except Exception as e: + logger.error(f"发送企业微信消息失败: {e}") + return False + + def _send_single_message(self, content: str) -> bool: + """发送单条消息""" + payload = { + "msgtype": "markdown", + "markdown": { + "content": content + } + } + + response = requests.post( + self._webhook_url, + json=payload, + timeout=10 + ) + + if response.status_code == 200: + result = response.json() + if result.get('errcode') == 0: + logger.info("企业微信消息发送成功") + return True + else: + logger.error(f"企业微信返回错误: {result}") + return False + else: + logger.error(f"企业微信请求失败: {response.status_code}") + return False + + def _send_chunked_messages(self, content: str, max_length: int) -> bool: + """ + 分段发送长消息 + + 按段落(---)分割,确保每段不超过最大长度 + """ + # 按分隔线分割 + sections = content.split("\n---\n") + + current_chunk = [] + current_length = 0 + all_success = True + chunk_index = 1 + + for section in sections: + section_with_divider = section + "\n---\n" + section_length = len(section_with_divider) + + if current_length + section_length > max_length: + # 发送当前块 + if current_chunk: + chunk_content = "\n---\n".join(current_chunk) + logger.info(f"发送消息块 {chunk_index}...") + if not self._send_single_message(chunk_content): + all_success = False + chunk_index += 1 + + # 重置 + current_chunk = [section] + current_length = section_length + else: + current_chunk.append(section) + current_length += section_length + + # 发送最后一块 + if current_chunk: + chunk_content = "\n---\n".join(current_chunk) + logger.info(f"发送消息块 {chunk_index}(最后)...") + if not self._send_single_message(chunk_content): + all_success = False + + return all_success + + def save_report_to_file( + self, + content: str, + filename: Optional[str] = None + ) -> str: + """ + 保存日报到本地文件 + + Args: + content: 日报内容 + filename: 文件名(可选,默认按日期生成) + + Returns: + 保存的文件路径 + """ + from pathlib import Path + + if filename is None: + date_str = datetime.now().strftime('%Y%m%d') + filename = f"report_{date_str}.md" + + # 确保 reports 目录存在 + reports_dir = Path(__file__).parent / 'reports' + reports_dir.mkdir(parents=True, exist_ok=True) + + filepath = reports_dir / filename + + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content) + + logger.info(f"日报已保存到: {filepath}") + return str(filepath) + + +class NotificationBuilder: + """ + 通知消息构建器 + + 提供便捷的消息构建方法 + """ + + @staticmethod + def build_simple_alert( + title: str, + content: str, + alert_type: str = "info" + ) -> str: + """ + 构建简单的提醒消息 + + Args: + title: 标题 + content: 内容 + alert_type: 类型(info, warning, error, success) + """ + emoji_map = { + "info": "ℹ️", + "warning": "⚠️", + "error": "❌", + "success": "✅", + } + emoji = emoji_map.get(alert_type, "📢") + + return f"{emoji} **{title}**\n\n{content}" + + @staticmethod + def build_stock_summary(results: List[AnalysisResult]) -> str: + """ + 构建股票摘要(简短版) + + 适用于快速通知 + """ + lines = ["📊 **今日自选股摘要**", ""] + + for r in sorted(results, key=lambda x: x.sentiment_score, reverse=True): + emoji = r.get_emoji() + lines.append(f"{emoji} {r.name}({r.code}): {r.operation_advice} | 评分 {r.sentiment_score}") + + return "\n".join(lines) + + +# 便捷函数 +def get_notification_service() -> NotificationService: + """获取通知服务实例""" + return NotificationService() + + +def send_daily_report(results: List[AnalysisResult]) -> bool: + """ + 发送每日报告的快捷方式 + + 自动生成报告并推送到企业微信 + """ + service = get_notification_service() + + # 生成报告 + report = service.generate_daily_report(results) + + # 保存到本地 + service.save_report_to_file(report) + + # 推送到企业微信 + return service.send_to_wechat(report) + + +if __name__ == "__main__": + # 测试代码 + logging.basicConfig(level=logging.DEBUG) + + # 模拟分析结果 + test_results = [ + AnalysisResult( + code='600519', + name='贵州茅台', + sentiment_score=75, + trend_prediction='看多', + analysis_summary='技术面强势,消息面利好', + operation_advice='买入', + technical_analysis='放量突破 MA20,MACD 金叉', + news_summary='公司发布分红公告,业绩超预期', + ), + AnalysisResult( + code='000001', + name='平安银行', + sentiment_score=45, + trend_prediction='震荡', + analysis_summary='横盘整理,等待方向', + operation_advice='持有', + technical_analysis='均线粘合,成交量萎缩', + news_summary='近期无重大消息', + ), + AnalysisResult( + code='300750', + name='宁德时代', + sentiment_score=35, + trend_prediction='看空', + analysis_summary='技术面走弱,注意风险', + operation_advice='卖出', + technical_analysis='跌破 MA10 支撑,量能不足', + news_summary='行业竞争加剧,毛利率承压', + ), + ] + + service = NotificationService() + + # 生成日报 + print("=== 生成日报测试 ===") + report = service.generate_daily_report(test_results) + print(report) + + # 保存到文件 + print("\n=== 保存日报 ===") + filepath = service.save_report_to_file(report) + print(f"保存成功: {filepath}") + + # 推送测试(仅当配置了 Webhook 时) + if service.is_available(): + print("\n=== 推送测试 ===") + success = service.send_to_wechat(report) + print(f"推送结果: {'成功' if success else '失败'}") + else: + print("\n企业微信 Webhook 未配置,跳过推送测试") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..72e83b29d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,33 @@ +# =================================== +# A股自选股智能分析系统 - 依赖列表 +# =================================== + +# 核心依赖 +python-dotenv>=1.0.0 # 环境变量配置管理 +tenacity>=8.2.0 # 重试机制(指数退避) +sqlalchemy>=2.0.0 # ORM数据库操作 +schedule>=1.2.0 # 定时任务调度 + +# 数据源依赖(多源策略) +akshare>=1.12.0 # Primary: 东方财富爬虫数据源 +tushare>=1.4.0 # Backup 1: 挖地兔 Pro API +baostock>=0.8.0 # Backup 2: 证券宝数据 +yfinance>=0.2.0 # Fallback: Yahoo Finance + +# 数据处理 +pandas>=2.0.0 # 数据分析 +numpy>=1.24.0 # 数值计算 + +# AI 分析 +google-generativeai>=0.8.0 # Gemini API + +# 搜索引擎(用于获取股票新闻) +tavily-python>=0.3.0 # Tavily 搜索 API(每月 1000 次免费) +google-search-results>=2.4.0 # SerpAPI(每月 100 次免费) + +# 网络请求 +requests>=2.31.0 # HTTP 请求 +fake-useragent>=1.4.0 # 随机 User-Agent 防封禁 + +# 数据库 +# SQLite 是 Python 内置,无需额外安装 diff --git a/scheduler.py b/scheduler.py new file mode 100644 index 000000000..4678f01d6 --- /dev/null +++ b/scheduler.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +""" +=================================== +定时调度模块 +=================================== + +职责: +1. 支持每日定时执行股票分析 +2. 支持定时执行大盘复盘 +3. 优雅处理信号,确保可靠退出 + +依赖: +- schedule: 轻量级定时任务库 +""" + +import logging +import signal +import sys +import time +import threading +from datetime import datetime +from typing import Callable, Optional + +logger = logging.getLogger(__name__) + + +class GracefulShutdown: + """ + 优雅退出处理器 + + 捕获 SIGTERM/SIGINT 信号,确保任务完成后再退出 + """ + + def __init__(self): + self.shutdown_requested = False + self._lock = threading.Lock() + + # 注册信号处理器 + signal.signal(signal.SIGINT, self._signal_handler) + signal.signal(signal.SIGTERM, self._signal_handler) + + def _signal_handler(self, signum, frame): + """信号处理函数""" + with self._lock: + if not self.shutdown_requested: + logger.info(f"收到退出信号 ({signum}),等待当前任务完成...") + self.shutdown_requested = True + + @property + def should_shutdown(self) -> bool: + """检查是否应该退出""" + with self._lock: + return self.shutdown_requested + + +class Scheduler: + """ + 定时任务调度器 + + 基于 schedule 库实现,支持: + - 每日定时执行 + - 启动时立即执行 + - 优雅退出 + """ + + def __init__(self, schedule_time: str = "18:00"): + """ + 初始化调度器 + + Args: + schedule_time: 每日执行时间,格式 "HH:MM" + """ + try: + import schedule + self.schedule = schedule + except ImportError: + logger.error("schedule 库未安装,请执行: pip install schedule") + raise ImportError("请安装 schedule 库: pip install schedule") + + self.schedule_time = schedule_time + self.shutdown_handler = GracefulShutdown() + self._task_callback: Optional[Callable] = None + self._running = False + + def set_daily_task(self, task: Callable, run_immediately: bool = True): + """ + 设置每日定时任务 + + Args: + task: 要执行的任务函数(无参数) + run_immediately: 是否在设置后立即执行一次 + """ + self._task_callback = task + + # 设置每日定时任务 + self.schedule.every().day.at(self.schedule_time).do(self._safe_run_task) + logger.info(f"已设置每日定时任务,执行时间: {self.schedule_time}") + + if run_immediately: + logger.info("立即执行一次任务...") + self._safe_run_task() + + def _safe_run_task(self): + """安全执行任务(带异常捕获)""" + if self._task_callback is None: + return + + try: + logger.info("=" * 50) + logger.info(f"定时任务开始执行 - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + logger.info("=" * 50) + + self._task_callback() + + logger.info(f"定时任务执行完成 - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + except Exception as e: + logger.exception(f"定时任务执行失败: {e}") + + def run(self): + """ + 运行调度器主循环 + + 阻塞运行,直到收到退出信号 + """ + self._running = True + logger.info("调度器开始运行...") + logger.info(f"下次执行时间: {self._get_next_run_time()}") + + while self._running and not self.shutdown_handler.should_shutdown: + self.schedule.run_pending() + time.sleep(30) # 每30秒检查一次 + + # 每小时打印一次心跳 + if datetime.now().minute == 0 and datetime.now().second < 30: + logger.info(f"调度器运行中... 下次执行: {self._get_next_run_time()}") + + logger.info("调度器已停止") + + def _get_next_run_time(self) -> str: + """获取下次执行时间""" + jobs = self.schedule.get_jobs() + if jobs: + next_run = min(job.next_run for job in jobs) + return next_run.strftime('%Y-%m-%d %H:%M:%S') + return "未设置" + + def stop(self): + """停止调度器""" + self._running = False + + +def run_with_schedule( + task: Callable, + schedule_time: str = "18:00", + run_immediately: bool = True +): + """ + 便捷函数:使用定时调度运行任务 + + Args: + task: 要执行的任务函数 + schedule_time: 每日执行时间 + run_immediately: 是否立即执行一次 + """ + scheduler = Scheduler(schedule_time=schedule_time) + scheduler.set_daily_task(task, run_immediately=run_immediately) + scheduler.run() + + +if __name__ == "__main__": + # 测试定时调度 + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s', + ) + + def test_task(): + print(f"任务执行中... {datetime.now()}") + time.sleep(2) + print("任务完成!") + + print("启动测试调度器(按 Ctrl+C 退出)") + run_with_schedule(test_task, schedule_time="23:59", run_immediately=True) diff --git a/search_service.py b/search_service.py new file mode 100644 index 000000000..971a50ba4 --- /dev/null +++ b/search_service.py @@ -0,0 +1,696 @@ +# -*- coding: utf-8 -*- +""" +=================================== +A股自选股智能分析系统 - 搜索服务模块 +=================================== + +职责: +1. 提供统一的新闻搜索接口 +2. 支持 Tavily 和 SerpAPI 两种搜索引擎 +3. 多 Key 负载均衡和故障转移 +4. 搜索结果缓存和格式化 +""" + +import logging +import random +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime +from typing import List, Dict, Any, Optional +from itertools import cycle + +logger = logging.getLogger(__name__) + + +@dataclass +class SearchResult: + """搜索结果数据类""" + title: str + snippet: str # 摘要 + url: str + source: str # 来源网站 + published_date: Optional[str] = None + + def to_text(self) -> str: + """转换为文本格式""" + date_str = f" ({self.published_date})" if self.published_date else "" + return f"【{self.source}】{self.title}{date_str}\n{self.snippet}" + + +@dataclass +class SearchResponse: + """搜索响应""" + query: str + results: List[SearchResult] + provider: str # 使用的搜索引擎 + success: bool = True + error_message: Optional[str] = None + search_time: float = 0.0 # 搜索耗时(秒) + + def to_context(self, max_results: int = 5) -> str: + """将搜索结果转换为可用于 AI 分析的上下文""" + if not self.success or not self.results: + return f"搜索 '{self.query}' 未找到相关结果。" + + lines = [f"【{self.query} 搜索结果】(来源:{self.provider})"] + for i, result in enumerate(self.results[:max_results], 1): + lines.append(f"\n{i}. {result.to_text()}") + + return "\n".join(lines) + + +class BaseSearchProvider(ABC): + """搜索引擎基类""" + + def __init__(self, api_keys: List[str], name: str): + """ + 初始化搜索引擎 + + Args: + api_keys: API Key 列表(支持多个 key 负载均衡) + name: 搜索引擎名称 + """ + self._api_keys = api_keys + self._name = name + self._key_cycle = cycle(api_keys) if api_keys else None + self._key_usage: Dict[str, int] = {key: 0 for key in api_keys} + self._key_errors: Dict[str, int] = {key: 0 for key in api_keys} + + @property + def name(self) -> str: + return self._name + + @property + def is_available(self) -> bool: + """检查是否有可用的 API Key""" + return bool(self._api_keys) + + def _get_next_key(self) -> Optional[str]: + """ + 获取下一个可用的 API Key(负载均衡) + + 策略:轮询 + 跳过错误过多的 key + """ + if not self._key_cycle: + return None + + # 最多尝试所有 key + for _ in range(len(self._api_keys)): + key = next(self._key_cycle) + # 跳过错误次数过多的 key(超过 3 次) + if self._key_errors.get(key, 0) < 3: + return key + + # 所有 key 都有问题,重置错误计数并返回第一个 + logger.warning(f"[{self._name}] 所有 API Key 都有错误记录,重置错误计数") + self._key_errors = {key: 0 for key in self._api_keys} + return self._api_keys[0] if self._api_keys else None + + def _record_success(self, key: str) -> None: + """记录成功使用""" + self._key_usage[key] = self._key_usage.get(key, 0) + 1 + # 成功后减少错误计数 + if key in self._key_errors and self._key_errors[key] > 0: + self._key_errors[key] -= 1 + + def _record_error(self, key: str) -> None: + """记录错误""" + self._key_errors[key] = self._key_errors.get(key, 0) + 1 + logger.warning(f"[{self._name}] API Key {key[:8]}... 错误计数: {self._key_errors[key]}") + + @abstractmethod + def _do_search(self, query: str, api_key: str, max_results: int) -> SearchResponse: + """执行搜索(子类实现)""" + pass + + def search(self, query: str, max_results: int = 5) -> SearchResponse: + """ + 执行搜索 + + Args: + query: 搜索关键词 + max_results: 最大返回结果数 + + Returns: + SearchResponse 对象 + """ + api_key = self._get_next_key() + if not api_key: + return SearchResponse( + query=query, + results=[], + provider=self._name, + success=False, + error_message=f"{self._name} 未配置 API Key" + ) + + start_time = time.time() + try: + response = self._do_search(query, api_key, max_results) + response.search_time = time.time() - start_time + + if response.success: + self._record_success(api_key) + logger.info(f"[{self._name}] 搜索 '{query}' 成功,返回 {len(response.results)} 条结果,耗时 {response.search_time:.2f}s") + else: + self._record_error(api_key) + + return response + + except Exception as e: + self._record_error(api_key) + elapsed = time.time() - start_time + logger.error(f"[{self._name}] 搜索 '{query}' 失败: {e}") + return SearchResponse( + query=query, + results=[], + provider=self._name, + success=False, + error_message=str(e), + search_time=elapsed + ) + + +class TavilySearchProvider(BaseSearchProvider): + """ + Tavily 搜索引擎 + + 特点: + - 专为 AI/LLM 优化的搜索 API + - 免费版每月 1000 次请求 + - 返回结构化的搜索结果 + + 文档:https://docs.tavily.com/ + """ + + def __init__(self, api_keys: List[str]): + super().__init__(api_keys, "Tavily") + + def _do_search(self, query: str, api_key: str, max_results: int) -> SearchResponse: + """执行 Tavily 搜索""" + try: + from tavily import TavilyClient + except ImportError: + return SearchResponse( + query=query, + results=[], + provider=self.name, + success=False, + error_message="tavily-python 未安装,请运行: pip install tavily-python" + ) + + try: + client = TavilyClient(api_key=api_key) + + # 执行搜索(优化:使用advanced深度、限制最近7天) + response = client.search( + query=query, + search_depth="advanced", # advanced 获取更多结果 + max_results=max_results, + include_answer=False, + include_raw_content=False, + days=7, # 只搜索最近7天的内容 + ) + + # 记录原始响应到日志 + logger.info(f"[Tavily] 搜索完成,query='{query}', 返回 {len(response.get('results', []))} 条结果") + logger.debug(f"[Tavily] 原始响应: {response}") + + # 解析结果 + results = [] + for item in response.get('results', []): + results.append(SearchResult( + title=item.get('title', ''), + snippet=item.get('content', '')[:500], # 截取前500字 + url=item.get('url', ''), + source=self._extract_domain(item.get('url', '')), + published_date=item.get('published_date'), + )) + + return SearchResponse( + query=query, + results=results, + provider=self.name, + success=True, + ) + + except Exception as e: + error_msg = str(e) + # 检查是否是配额问题 + if 'rate limit' in error_msg.lower() or 'quota' in error_msg.lower(): + error_msg = f"API 配额已用尽: {error_msg}" + + return SearchResponse( + query=query, + results=[], + provider=self.name, + success=False, + error_message=error_msg + ) + + @staticmethod + def _extract_domain(url: str) -> str: + """从 URL 提取域名作为来源""" + try: + from urllib.parse import urlparse + parsed = urlparse(url) + domain = parsed.netloc.replace('www.', '') + return domain or '未知来源' + except: + return '未知来源' + + +class SerpAPISearchProvider(BaseSearchProvider): + """ + SerpAPI 搜索引擎 + + 特点: + - 支持 Google、Bing、百度等多种搜索引擎 + - 免费版每月 100 次请求 + - 返回真实的搜索结果 + + 文档:https://serpapi.com/ + """ + + def __init__(self, api_keys: List[str]): + super().__init__(api_keys, "SerpAPI") + + def _do_search(self, query: str, api_key: str, max_results: int) -> SearchResponse: + """执行 SerpAPI 搜索""" + try: + from serpapi import GoogleSearch + except ImportError: + return SearchResponse( + query=query, + results=[], + provider=self.name, + success=False, + error_message="google-search-results 未安装,请运行: pip install google-search-results" + ) + + try: + # 使用百度搜索(对中文股票新闻更友好) + params = { + "engine": "baidu", # 使用百度搜索 + "q": query, + "api_key": api_key, + } + + search = GoogleSearch(params) + response = search.get_dict() + + # 记录原始响应到日志 + logger.debug(f"[SerpAPI] 原始响应 keys: {response.keys()}") + + # 解析结果 + results = [] + organic_results = response.get('organic_results', []) + + for item in organic_results[:max_results]: + results.append(SearchResult( + title=item.get('title', ''), + snippet=item.get('snippet', '')[:500], + url=item.get('link', ''), + source=item.get('source', self._extract_domain(item.get('link', ''))), + published_date=item.get('date'), + )) + + return SearchResponse( + query=query, + results=results, + provider=self.name, + success=True, + ) + + except Exception as e: + error_msg = str(e) + return SearchResponse( + query=query, + results=[], + provider=self.name, + success=False, + error_message=error_msg + ) + + @staticmethod + def _extract_domain(url: str) -> str: + """从 URL 提取域名""" + try: + from urllib.parse import urlparse + parsed = urlparse(url) + return parsed.netloc.replace('www.', '') or '未知来源' + except: + return '未知来源' + + +class SearchService: + """ + 搜索服务 + + 功能: + 1. 管理多个搜索引擎 + 2. 自动故障转移 + 3. 结果聚合和格式化 + """ + + def __init__( + self, + tavily_keys: Optional[List[str]] = None, + serpapi_keys: Optional[List[str]] = None, + ): + """ + 初始化搜索服务 + + Args: + tavily_keys: Tavily API Key 列表 + serpapi_keys: SerpAPI Key 列表 + """ + self._providers: List[BaseSearchProvider] = [] + + # 初始化搜索引擎(按优先级排序) + # Tavily 优先(免费额度更多,每月 1000 次) + if tavily_keys: + self._providers.append(TavilySearchProvider(tavily_keys)) + logger.info(f"已配置 Tavily 搜索,共 {len(tavily_keys)} 个 API Key") + + # SerpAPI 作为备选(每月 100 次) + if serpapi_keys: + self._providers.append(SerpAPISearchProvider(serpapi_keys)) + logger.info(f"已配置 SerpAPI 搜索,共 {len(serpapi_keys)} 个 API Key") + + if not self._providers: + logger.warning("未配置任何搜索引擎 API Key,新闻搜索功能将不可用") + + @property + def is_available(self) -> bool: + """检查是否有可用的搜索引擎""" + return any(p.is_available for p in self._providers) + + def search_stock_news( + self, + stock_code: str, + stock_name: str, + max_results: int = 5, + focus_keywords: Optional[List[str]] = None + ) -> SearchResponse: + """ + 搜索股票相关新闻 + + Args: + stock_code: 股票代码 + stock_name: 股票名称 + max_results: 最大返回结果数 + focus_keywords: 重点关注的关键词列表 + + Returns: + SearchResponse 对象 + """ + # 默认重点关注关键词(基于交易理念) + if focus_keywords is None: + focus_keywords = [ + "年报预告", "业绩预告", "业绩快报", # 业绩相关 + "减持", "增持", "回购", # 股东动向 + "机构调研", "机构评级", # 机构动向 + "利好", "利空", # 消息面 + "合同", "订单", "中标", # 业务进展 + ] + + # 构建搜索查询(优化搜索效果) + # 主查询:股票名称 + 核心关键词 + query = f"{stock_name} {stock_code} 股票 最新消息" + + logger.info(f"搜索股票新闻: {stock_name}({stock_code})") + + # 依次尝试各个搜索引擎 + for provider in self._providers: + if not provider.is_available: + continue + + response = provider.search(query, max_results) + + if response.success and response.results: + logger.info(f"使用 {provider.name} 搜索成功") + return response + else: + logger.warning(f"{provider.name} 搜索失败: {response.error_message},尝试下一个引擎") + + # 所有引擎都失败 + return SearchResponse( + query=query, + results=[], + provider="None", + success=False, + error_message="所有搜索引擎都不可用或搜索失败" + ) + + def search_stock_events( + self, + stock_code: str, + stock_name: str, + event_types: Optional[List[str]] = None + ) -> SearchResponse: + """ + 搜索股票特定事件(年报预告、减持等) + + 专门针对交易决策相关的重要事件进行搜索 + + Args: + stock_code: 股票代码 + stock_name: 股票名称 + event_types: 事件类型列表 + + Returns: + SearchResponse 对象 + """ + if event_types is None: + event_types = ["年报预告", "减持公告", "业绩快报"] + + # 构建针对性查询 + event_query = " OR ".join(event_types) + query = f"{stock_name} ({event_query})" + + logger.info(f"搜索股票事件: {stock_name}({stock_code}) - {event_types}") + + # 依次尝试各个搜索引擎 + for provider in self._providers: + if not provider.is_available: + continue + + response = provider.search(query, max_results=5) + + if response.success: + return response + + return SearchResponse( + query=query, + results=[], + provider="None", + success=False, + error_message="事件搜索失败" + ) + + def search_comprehensive_intel( + self, + stock_code: str, + stock_name: str, + max_searches: int = 3 + ) -> Dict[str, SearchResponse]: + """ + 多维度情报搜索(同时使用多个引擎、多个维度) + + 搜索维度: + 1. 最新消息 - 近期新闻动态 + 2. 风险排查 - 减持、处罚、利空 + 3. 业绩预期 - 年报预告、业绩快报 + + Args: + stock_code: 股票代码 + stock_name: 股票名称 + max_searches: 最大搜索次数 + + Returns: + {维度名称: SearchResponse} 字典 + """ + results = {} + search_count = 0 + + # 定义搜索维度 + search_dimensions = [ + { + 'name': 'latest_news', + 'query': f"{stock_name} {stock_code} 最新 新闻 2026年1月", + 'desc': '最新消息' + }, + { + 'name': 'risk_check', + 'query': f"{stock_name} 减持 处罚 利空 风险", + 'desc': '风险排查' + }, + { + 'name': 'earnings', + 'query': f"{stock_name} 年报预告 业绩预告 业绩快报 2025年报", + 'desc': '业绩预期' + }, + ] + + logger.info(f"开始多维度情报搜索: {stock_name}({stock_code})") + + # 轮流使用不同的搜索引擎 + provider_index = 0 + + for dim in search_dimensions: + if search_count >= max_searches: + break + + # 选择搜索引擎(轮流使用) + available_providers = [p for p in self._providers if p.is_available] + if not available_providers: + break + + provider = available_providers[provider_index % len(available_providers)] + provider_index += 1 + + logger.info(f"[情报搜索] {dim['desc']}: 使用 {provider.name}") + + response = provider.search(dim['query'], max_results=3) + results[dim['name']] = response + search_count += 1 + + if response.success: + logger.info(f"[情报搜索] {dim['desc']}: 获取 {len(response.results)} 条结果") + else: + logger.warning(f"[情报搜索] {dim['desc']}: 搜索失败 - {response.error_message}") + + # 短暂延迟避免请求过快 + time.sleep(0.5) + + return results + + def format_intel_report(self, intel_results: Dict[str, SearchResponse], stock_name: str) -> str: + """ + 格式化情报搜索结果为报告 + + Args: + intel_results: 多维度搜索结果 + stock_name: 股票名称 + + Returns: + 格式化的情报报告文本 + """ + lines = [f"【{stock_name} 情报搜索结果】"] + + # 最新消息 + if 'latest_news' in intel_results: + resp = intel_results['latest_news'] + lines.append(f"\n📰 最新消息 (来源: {resp.provider}):") + if resp.success and resp.results: + for i, r in enumerate(resp.results[:3], 1): + date_str = f" [{r.published_date}]" if r.published_date else "" + lines.append(f" {i}. {r.title}{date_str}") + lines.append(f" {r.snippet[:100]}...") + else: + lines.append(" 未找到相关消息") + + # 风险排查 + if 'risk_check' in intel_results: + resp = intel_results['risk_check'] + lines.append(f"\n⚠️ 风险排查 (来源: {resp.provider}):") + if resp.success and resp.results: + for i, r in enumerate(resp.results[:3], 1): + lines.append(f" {i}. {r.title}") + lines.append(f" {r.snippet[:100]}...") + else: + lines.append(" 未发现明显风险信号") + + # 业绩预期 + if 'earnings' in intel_results: + resp = intel_results['earnings'] + lines.append(f"\n📊 业绩预期 (来源: {resp.provider}):") + if resp.success and resp.results: + for i, r in enumerate(resp.results[:3], 1): + lines.append(f" {i}. {r.title}") + lines.append(f" {r.snippet[:100]}...") + else: + lines.append(" 未找到业绩相关信息") + + return "\n".join(lines) + + def batch_search( + self, + stocks: List[Dict[str, str]], + max_results_per_stock: int = 3, + delay_between: float = 1.0 + ) -> Dict[str, SearchResponse]: + """ + 批量搜索多只股票新闻 + + Args: + stocks: 股票列表 [{"code": "300389", "name": "艾比森"}, ...] + max_results_per_stock: 每只股票的最大结果数 + delay_between: 每次搜索之间的延迟(秒) + + Returns: + {股票代码: SearchResponse} 字典 + """ + results = {} + + for i, stock in enumerate(stocks): + if i > 0: + time.sleep(delay_between) + + code = stock.get('code', '') + name = stock.get('name', '') + + response = self.search_stock_news(code, name, max_results_per_stock) + results[code] = response + + return results + + +# === 便捷函数 === +_search_service: Optional[SearchService] = None + + +def get_search_service() -> SearchService: + """获取搜索服务单例""" + global _search_service + + if _search_service is None: + from config import get_config + config = get_config() + + _search_service = SearchService( + tavily_keys=config.tavily_api_keys, + serpapi_keys=config.serpapi_keys, + ) + + return _search_service + + +def reset_search_service() -> None: + """重置搜索服务(用于测试)""" + global _search_service + _search_service = None + + +if __name__ == "__main__": + # 测试搜索服务 + logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s' + ) + + # 手动测试(需要配置 API Key) + service = get_search_service() + + if service.is_available: + print("=== 测试股票新闻搜索 ===") + response = service.search_stock_news("300389", "艾比森") + print(f"搜索状态: {'成功' if response.success else '失败'}") + print(f"搜索引擎: {response.provider}") + print(f"结果数量: {len(response.results)}") + print(f"耗时: {response.search_time:.2f}s") + print("\n" + response.to_context()) + else: + print("未配置搜索引擎 API Key,跳过测试") diff --git a/sources/sample.png b/sources/sample.png new file mode 100644 index 000000000..6e06ca01f Binary files /dev/null and b/sources/sample.png differ diff --git a/stock_analyzer.py b/stock_analyzer.py new file mode 100644 index 000000000..ff9d3fd95 --- /dev/null +++ b/stock_analyzer.py @@ -0,0 +1,535 @@ +# -*- coding: utf-8 -*- +""" +=================================== +趋势交易分析器 - 基于用户交易理念 +=================================== + +交易理念核心原则: +1. 严进策略 - 不追高,追求每笔交易成功率 +2. 趋势交易 - MA5>MA10>MA20 多头排列,顺势而为 +3. 效率优先 - 关注筹码结构好的股票 +4. 买点偏好 - 在 MA5/MA10 附近回踩买入 + +技术标准: +- 多头排列:MA5 > MA10 > MA20 +- 乖离率:(Close - MA5) / MA5 < 5%(不追高) +- 量能形态:缩量回调优先 +""" + +import logging +from dataclasses import dataclass, field +from typing import Optional, Dict, Any, List, Tuple +from enum import Enum + +import pandas as pd +import numpy as np + +logger = logging.getLogger(__name__) + + +class TrendStatus(Enum): + """趋势状态枚举""" + STRONG_BULL = "强势多头" # MA5 > MA10 > MA20,且间距扩大 + BULL = "多头排列" # MA5 > MA10 > MA20 + WEAK_BULL = "弱势多头" # MA5 > MA10,但 MA10 < MA20 + CONSOLIDATION = "盘整" # 均线缠绕 + WEAK_BEAR = "弱势空头" # MA5 < MA10,但 MA10 > MA20 + BEAR = "空头排列" # MA5 < MA10 < MA20 + STRONG_BEAR = "强势空头" # MA5 < MA10 < MA20,且间距扩大 + + +class VolumeStatus(Enum): + """量能状态枚举""" + HEAVY_VOLUME_UP = "放量上涨" # 量价齐升 + HEAVY_VOLUME_DOWN = "放量下跌" # 放量杀跌 + SHRINK_VOLUME_UP = "缩量上涨" # 无量上涨 + SHRINK_VOLUME_DOWN = "缩量回调" # 缩量回调(好) + NORMAL = "量能正常" + + +class BuySignal(Enum): + """买入信号枚举""" + STRONG_BUY = "强烈买入" # 多条件满足 + BUY = "买入" # 基本条件满足 + HOLD = "持有" # 已持有可继续 + WAIT = "观望" # 等待更好时机 + SELL = "卖出" # 趋势转弱 + STRONG_SELL = "强烈卖出" # 趋势破坏 + + +@dataclass +class TrendAnalysisResult: + """趋势分析结果""" + code: str + + # 趋势判断 + trend_status: TrendStatus = TrendStatus.CONSOLIDATION + ma_alignment: str = "" # 均线排列描述 + trend_strength: float = 0.0 # 趋势强度 0-100 + + # 均线数据 + ma5: float = 0.0 + ma10: float = 0.0 + ma20: float = 0.0 + ma60: float = 0.0 + current_price: float = 0.0 + + # 乖离率(与 MA5 的偏离度) + bias_ma5: float = 0.0 # (Close - MA5) / MA5 * 100 + bias_ma10: float = 0.0 + bias_ma20: float = 0.0 + + # 量能分析 + volume_status: VolumeStatus = VolumeStatus.NORMAL + volume_ratio_5d: float = 0.0 # 当日成交量/5日均量 + volume_trend: str = "" # 量能趋势描述 + + # 支撑压力 + support_ma5: bool = False # MA5 是否构成支撑 + support_ma10: bool = False # MA10 是否构成支撑 + resistance_levels: List[float] = field(default_factory=list) + support_levels: List[float] = field(default_factory=list) + + # 买入信号 + buy_signal: BuySignal = BuySignal.WAIT + signal_score: int = 0 # 综合评分 0-100 + signal_reasons: List[str] = field(default_factory=list) + risk_factors: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + 'code': self.code, + 'trend_status': self.trend_status.value, + 'ma_alignment': self.ma_alignment, + 'trend_strength': self.trend_strength, + 'ma5': self.ma5, + 'ma10': self.ma10, + 'ma20': self.ma20, + 'ma60': self.ma60, + 'current_price': self.current_price, + 'bias_ma5': self.bias_ma5, + 'bias_ma10': self.bias_ma10, + 'bias_ma20': self.bias_ma20, + 'volume_status': self.volume_status.value, + 'volume_ratio_5d': self.volume_ratio_5d, + 'volume_trend': self.volume_trend, + 'support_ma5': self.support_ma5, + 'support_ma10': self.support_ma10, + 'buy_signal': self.buy_signal.value, + 'signal_score': self.signal_score, + 'signal_reasons': self.signal_reasons, + 'risk_factors': self.risk_factors, + } + + +class StockTrendAnalyzer: + """ + 股票趋势分析器 + + 基于用户交易理念实现: + 1. 趋势判断 - MA5>MA10>MA20 多头排列 + 2. 乖离率检测 - 不追高,偏离 MA5 超过 5% 不买 + 3. 量能分析 - 偏好缩量回调 + 4. 买点识别 - 回踩 MA5/MA10 支撑 + """ + + # 交易参数配置 + BIAS_THRESHOLD = 5.0 # 乖离率阈值(%),超过此值不买入 + VOLUME_SHRINK_RATIO = 0.7 # 缩量判断阈值(当日量/5日均量) + VOLUME_HEAVY_RATIO = 1.5 # 放量判断阈值 + MA_SUPPORT_TOLERANCE = 0.02 # MA 支撑判断容忍度(2%) + + def __init__(self): + """初始化分析器""" + pass + + def analyze(self, df: pd.DataFrame, code: str) -> TrendAnalysisResult: + """ + 分析股票趋势 + + Args: + df: 包含 OHLCV 数据的 DataFrame + code: 股票代码 + + Returns: + TrendAnalysisResult 分析结果 + """ + result = TrendAnalysisResult(code=code) + + if df is None or df.empty or len(df) < 20: + logger.warning(f"{code} 数据不足,无法进行趋势分析") + result.risk_factors.append("数据不足,无法完成分析") + return result + + # 确保数据按日期排序 + df = df.sort_values('date').reset_index(drop=True) + + # 计算均线 + df = self._calculate_mas(df) + + # 获取最新数据 + latest = df.iloc[-1] + result.current_price = float(latest['close']) + result.ma5 = float(latest['MA5']) + result.ma10 = float(latest['MA10']) + result.ma20 = float(latest['MA20']) + result.ma60 = float(latest.get('MA60', 0)) + + # 1. 趋势判断 + self._analyze_trend(df, result) + + # 2. 乖离率计算 + self._calculate_bias(result) + + # 3. 量能分析 + self._analyze_volume(df, result) + + # 4. 支撑压力分析 + self._analyze_support_resistance(df, result) + + # 5. 生成买入信号 + self._generate_signal(result) + + return result + + def _calculate_mas(self, df: pd.DataFrame) -> pd.DataFrame: + """计算均线""" + df = df.copy() + df['MA5'] = df['close'].rolling(window=5).mean() + df['MA10'] = df['close'].rolling(window=10).mean() + df['MA20'] = df['close'].rolling(window=20).mean() + if len(df) >= 60: + df['MA60'] = df['close'].rolling(window=60).mean() + else: + df['MA60'] = df['MA20'] # 数据不足时使用 MA20 替代 + return df + + def _analyze_trend(self, df: pd.DataFrame, result: TrendAnalysisResult) -> None: + """ + 分析趋势状态 + + 核心逻辑:判断均线排列和趋势强度 + """ + ma5, ma10, ma20 = result.ma5, result.ma10, result.ma20 + + # 判断均线排列 + if ma5 > ma10 > ma20: + # 检查间距是否在扩大(强势) + prev = df.iloc[-5] if len(df) >= 5 else df.iloc[-1] + prev_spread = (prev['MA5'] - prev['MA20']) / prev['MA20'] * 100 if prev['MA20'] > 0 else 0 + curr_spread = (ma5 - ma20) / ma20 * 100 if ma20 > 0 else 0 + + if curr_spread > prev_spread and curr_spread > 5: + result.trend_status = TrendStatus.STRONG_BULL + result.ma_alignment = "强势多头排列,均线发散上行" + result.trend_strength = 90 + else: + result.trend_status = TrendStatus.BULL + result.ma_alignment = "多头排列 MA5>MA10>MA20" + result.trend_strength = 75 + + elif ma5 > ma10 and ma10 <= ma20: + result.trend_status = TrendStatus.WEAK_BULL + result.ma_alignment = "弱势多头,MA5>MA10 但 MA10≤MA20" + result.trend_strength = 55 + + elif ma5 < ma10 < ma20: + prev = df.iloc[-5] if len(df) >= 5 else df.iloc[-1] + prev_spread = (prev['MA20'] - prev['MA5']) / prev['MA5'] * 100 if prev['MA5'] > 0 else 0 + curr_spread = (ma20 - ma5) / ma5 * 100 if ma5 > 0 else 0 + + if curr_spread > prev_spread and curr_spread > 5: + result.trend_status = TrendStatus.STRONG_BEAR + result.ma_alignment = "强势空头排列,均线发散下行" + result.trend_strength = 10 + else: + result.trend_status = TrendStatus.BEAR + result.ma_alignment = "空头排列 MA5= ma20: + result.trend_status = TrendStatus.WEAK_BEAR + result.ma_alignment = "弱势空头,MA5 None: + """ + 计算乖离率 + + 乖离率 = (现价 - 均线) / 均线 * 100% + + 严进策略:乖离率超过 5% 不追高 + """ + price = result.current_price + + if result.ma5 > 0: + result.bias_ma5 = (price - result.ma5) / result.ma5 * 100 + if result.ma10 > 0: + result.bias_ma10 = (price - result.ma10) / result.ma10 * 100 + if result.ma20 > 0: + result.bias_ma20 = (price - result.ma20) / result.ma20 * 100 + + def _analyze_volume(self, df: pd.DataFrame, result: TrendAnalysisResult) -> None: + """ + 分析量能 + + 偏好:缩量回调 > 放量上涨 > 缩量上涨 > 放量下跌 + """ + if len(df) < 5: + return + + latest = df.iloc[-1] + vol_5d_avg = df['volume'].iloc[-6:-1].mean() + + if vol_5d_avg > 0: + result.volume_ratio_5d = float(latest['volume']) / vol_5d_avg + + # 判断价格变化 + prev_close = df.iloc[-2]['close'] + price_change = (latest['close'] - prev_close) / prev_close * 100 + + # 量能状态判断 + if result.volume_ratio_5d >= self.VOLUME_HEAVY_RATIO: + if price_change > 0: + result.volume_status = VolumeStatus.HEAVY_VOLUME_UP + result.volume_trend = "放量上涨,多头力量强劲" + else: + result.volume_status = VolumeStatus.HEAVY_VOLUME_DOWN + result.volume_trend = "放量下跌,注意风险" + elif result.volume_ratio_5d <= self.VOLUME_SHRINK_RATIO: + if price_change > 0: + result.volume_status = VolumeStatus.SHRINK_VOLUME_UP + result.volume_trend = "缩量上涨,上攻动能不足" + else: + result.volume_status = VolumeStatus.SHRINK_VOLUME_DOWN + result.volume_trend = "缩量回调,洗盘特征明显(好)" + else: + result.volume_status = VolumeStatus.NORMAL + result.volume_trend = "量能正常" + + def _analyze_support_resistance(self, df: pd.DataFrame, result: TrendAnalysisResult) -> None: + """ + 分析支撑压力位 + + 买点偏好:回踩 MA5/MA10 获得支撑 + """ + price = result.current_price + + # 检查是否在 MA5 附近获得支撑 + if result.ma5 > 0: + ma5_distance = abs(price - result.ma5) / result.ma5 + if ma5_distance <= self.MA_SUPPORT_TOLERANCE and price >= result.ma5: + result.support_ma5 = True + result.support_levels.append(result.ma5) + + # 检查是否在 MA10 附近获得支撑 + if result.ma10 > 0: + ma10_distance = abs(price - result.ma10) / result.ma10 + if ma10_distance <= self.MA_SUPPORT_TOLERANCE and price >= result.ma10: + result.support_ma10 = True + if result.ma10 not in result.support_levels: + result.support_levels.append(result.ma10) + + # MA20 作为重要支撑 + if result.ma20 > 0 and price >= result.ma20: + result.support_levels.append(result.ma20) + + # 近期高点作为压力 + if len(df) >= 20: + recent_high = df['high'].iloc[-20:].max() + if recent_high > price: + result.resistance_levels.append(recent_high) + + def _generate_signal(self, result: TrendAnalysisResult) -> None: + """ + 生成买入信号 + + 综合评分系统: + - 趋势(40分):多头排列得分高 + - 乖离率(30分):接近 MA5 得分高 + - 量能(20分):缩量回调得分高 + - 支撑(10分):获得均线支撑得分高 + """ + score = 0 + reasons = [] + risks = [] + + # === 趋势评分(40分)=== + trend_scores = { + TrendStatus.STRONG_BULL: 40, + TrendStatus.BULL: 35, + TrendStatus.WEAK_BULL: 25, + TrendStatus.CONSOLIDATION: 15, + TrendStatus.WEAK_BEAR: 10, + TrendStatus.BEAR: 5, + TrendStatus.STRONG_BEAR: 0, + } + trend_score = trend_scores.get(result.trend_status, 15) + score += trend_score + + if result.trend_status in [TrendStatus.STRONG_BULL, TrendStatus.BULL]: + reasons.append(f"✅ {result.trend_status.value},顺势做多") + elif result.trend_status in [TrendStatus.BEAR, TrendStatus.STRONG_BEAR]: + risks.append(f"⚠️ {result.trend_status.value},不宜做多") + + # === 乖离率评分(30分)=== + bias = result.bias_ma5 + if bias < 0: + # 价格在 MA5 下方(回调中) + if bias > -3: + score += 30 + reasons.append(f"✅ 价格略低于MA5({bias:.1f}%),回踩买点") + elif bias > -5: + score += 25 + reasons.append(f"✅ 价格回踩MA5({bias:.1f}%),观察支撑") + else: + score += 10 + risks.append(f"⚠️ 乖离率过大({bias:.1f}%),可能破位") + elif bias < 2: + score += 28 + reasons.append(f"✅ 价格贴近MA5({bias:.1f}%),介入好时机") + elif bias < self.BIAS_THRESHOLD: + score += 20 + reasons.append(f"⚡ 价格略高于MA5({bias:.1f}%),可小仓介入") + else: + score += 5 + risks.append(f"❌ 乖离率过高({bias:.1f}%>5%),严禁追高!") + + # === 量能评分(20分)=== + volume_scores = { + VolumeStatus.SHRINK_VOLUME_DOWN: 20, # 缩量回调最佳 + VolumeStatus.HEAVY_VOLUME_UP: 15, # 放量上涨次之 + VolumeStatus.NORMAL: 12, + VolumeStatus.SHRINK_VOLUME_UP: 8, # 无量上涨较差 + VolumeStatus.HEAVY_VOLUME_DOWN: 0, # 放量下跌最差 + } + vol_score = volume_scores.get(result.volume_status, 10) + score += vol_score + + if result.volume_status == VolumeStatus.SHRINK_VOLUME_DOWN: + reasons.append("✅ 缩量回调,主力洗盘") + elif result.volume_status == VolumeStatus.HEAVY_VOLUME_DOWN: + risks.append("⚠️ 放量下跌,注意风险") + + # === 支撑评分(10分)=== + if result.support_ma5: + score += 5 + reasons.append("✅ MA5支撑有效") + if result.support_ma10: + score += 5 + reasons.append("✅ MA10支撑有效") + + # === 综合判断 === + result.signal_score = score + result.signal_reasons = reasons + result.risk_factors = risks + + # 生成买入信号 + if score >= 80 and result.trend_status in [TrendStatus.STRONG_BULL, TrendStatus.BULL]: + result.buy_signal = BuySignal.STRONG_BUY + elif score >= 65 and result.trend_status in [TrendStatus.STRONG_BULL, TrendStatus.BULL, TrendStatus.WEAK_BULL]: + result.buy_signal = BuySignal.BUY + elif score >= 50: + result.buy_signal = BuySignal.HOLD + elif score >= 35: + result.buy_signal = BuySignal.WAIT + elif result.trend_status in [TrendStatus.BEAR, TrendStatus.STRONG_BEAR]: + result.buy_signal = BuySignal.STRONG_SELL + else: + result.buy_signal = BuySignal.SELL + + def format_analysis(self, result: TrendAnalysisResult) -> str: + """ + 格式化分析结果为文本 + + Args: + result: 分析结果 + + Returns: + 格式化的分析文本 + """ + lines = [ + f"=== {result.code} 趋势分析 ===", + f"", + f"📊 趋势判断: {result.trend_status.value}", + f" 均线排列: {result.ma_alignment}", + f" 趋势强度: {result.trend_strength}/100", + f"", + f"📈 均线数据:", + f" 现价: {result.current_price:.2f}", + f" MA5: {result.ma5:.2f} (乖离 {result.bias_ma5:+.2f}%)", + f" MA10: {result.ma10:.2f} (乖离 {result.bias_ma10:+.2f}%)", + f" MA20: {result.ma20:.2f} (乖离 {result.bias_ma20:+.2f}%)", + f"", + f"📊 量能分析: {result.volume_status.value}", + f" 量比(vs5日): {result.volume_ratio_5d:.2f}", + f" 量能趋势: {result.volume_trend}", + f"", + f"🎯 操作建议: {result.buy_signal.value}", + f" 综合评分: {result.signal_score}/100", + ] + + if result.signal_reasons: + lines.append(f"") + lines.append(f"✅ 买入理由:") + for reason in result.signal_reasons: + lines.append(f" {reason}") + + if result.risk_factors: + lines.append(f"") + lines.append(f"⚠️ 风险因素:") + for risk in result.risk_factors: + lines.append(f" {risk}") + + return "\n".join(lines) + + +def analyze_stock(df: pd.DataFrame, code: str) -> TrendAnalysisResult: + """ + 便捷函数:分析单只股票 + + Args: + df: 包含 OHLCV 数据的 DataFrame + code: 股票代码 + + Returns: + TrendAnalysisResult 分析结果 + """ + analyzer = StockTrendAnalyzer() + return analyzer.analyze(df, code) + + +if __name__ == "__main__": + # 测试代码 + logging.basicConfig(level=logging.INFO) + + # 模拟数据测试 + import numpy as np + + dates = pd.date_range(start='2025-01-01', periods=60, freq='D') + np.random.seed(42) + + # 模拟多头排列的数据 + base_price = 10.0 + prices = [base_price] + for i in range(59): + change = np.random.randn() * 0.02 + 0.003 # 轻微上涨趋势 + prices.append(prices[-1] * (1 + change)) + + df = pd.DataFrame({ + 'date': dates, + 'open': prices, + 'high': [p * (1 + np.random.uniform(0, 0.02)) for p in prices], + 'low': [p * (1 - np.random.uniform(0, 0.02)) for p in prices], + 'close': prices, + 'volume': [np.random.randint(1000000, 5000000) for _ in prices], + }) + + analyzer = StockTrendAnalyzer() + result = analyzer.analyze(df, '000001') + print(analyzer.format_analysis(result)) diff --git a/storage.py b/storage.py new file mode 100644 index 000000000..2b4f3de24 --- /dev/null +++ b/storage.py @@ -0,0 +1,508 @@ +# -*- coding: utf-8 -*- +""" +=================================== +A股自选股智能分析系统 - 存储层 +=================================== + +职责: +1. 管理 SQLite 数据库连接(单例模式) +2. 定义 ORM 数据模型 +3. 提供数据存取接口 +4. 实现智能更新逻辑(断点续传) +""" + +import logging +from datetime import datetime, date, timedelta +from typing import Optional, List, Dict, Any +from pathlib import Path + +import pandas as pd +from sqlalchemy import ( + create_engine, + Column, + String, + Float, + Date, + DateTime, + Integer, + Index, + UniqueConstraint, + select, + and_, + desc, +) +from sqlalchemy.orm import ( + declarative_base, + sessionmaker, + Session, +) +from sqlalchemy.exc import IntegrityError + +from config import get_config + +logger = logging.getLogger(__name__) + +# SQLAlchemy ORM 基类 +Base = declarative_base() + + +# === 数据模型定义 === + +class StockDaily(Base): + """ + 股票日线数据模型 + + 存储每日行情数据和计算的技术指标 + 支持多股票、多日期的唯一约束 + """ + __tablename__ = 'stock_daily' + + # 主键 + id = Column(Integer, primary_key=True, autoincrement=True) + + # 股票代码(如 600519, 000001) + code = Column(String(10), nullable=False, index=True) + + # 交易日期 + date = Column(Date, nullable=False, index=True) + + # OHLC 数据 + open = Column(Float) + high = Column(Float) + low = Column(Float) + close = Column(Float) + + # 成交数据 + volume = Column(Float) # 成交量(股) + amount = Column(Float) # 成交额(元) + pct_chg = Column(Float) # 涨跌幅(%) + + # 技术指标 + ma5 = Column(Float) + ma10 = Column(Float) + ma20 = Column(Float) + volume_ratio = Column(Float) # 量比 + + # 数据来源 + data_source = Column(String(50)) # 记录数据来源(如 AkshareFetcher) + + # 更新时间 + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # 唯一约束:同一股票同一日期只能有一条数据 + __table_args__ = ( + UniqueConstraint('code', 'date', name='uix_code_date'), + Index('ix_code_date', 'code', 'date'), + ) + + def __repr__(self): + return f"" + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + 'code': self.code, + 'date': self.date, + 'open': self.open, + 'high': self.high, + 'low': self.low, + 'close': self.close, + 'volume': self.volume, + 'amount': self.amount, + 'pct_chg': self.pct_chg, + 'ma5': self.ma5, + 'ma10': self.ma10, + 'ma20': self.ma20, + 'volume_ratio': self.volume_ratio, + 'data_source': self.data_source, + } + + +class DatabaseManager: + """ + 数据库管理器 - 单例模式 + + 职责: + 1. 管理数据库连接池 + 2. 提供 Session 上下文管理 + 3. 封装数据存取操作 + """ + + _instance: Optional['DatabaseManager'] = None + + def __new__(cls, *args, **kwargs): + """单例模式实现""" + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self, db_url: Optional[str] = None): + """ + 初始化数据库管理器 + + Args: + db_url: 数据库连接 URL(可选,默认从配置读取) + """ + if self._initialized: + return + + if db_url is None: + config = get_config() + db_url = config.get_db_url() + + # 创建数据库引擎 + self._engine = create_engine( + db_url, + echo=False, # 设为 True 可查看 SQL 语句 + pool_pre_ping=True, # 连接健康检查 + ) + + # 创建 Session 工厂 + self._SessionLocal = sessionmaker( + bind=self._engine, + autocommit=False, + autoflush=False, + ) + + # 创建所有表 + Base.metadata.create_all(self._engine) + + self._initialized = True + logger.info(f"数据库初始化完成: {db_url}") + + @classmethod + def get_instance(cls) -> 'DatabaseManager': + """获取单例实例""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @classmethod + def reset_instance(cls) -> None: + """重置单例(用于测试)""" + if cls._instance is not None: + cls._instance._engine.dispose() + cls._instance = None + + def get_session(self) -> Session: + """ + 获取数据库 Session + + 使用示例: + with db.get_session() as session: + # 执行查询 + session.commit() # 如果需要 + """ + session = self._SessionLocal() + try: + return session + except Exception: + session.close() + raise + + def has_today_data(self, code: str, target_date: Optional[date] = None) -> bool: + """ + 检查是否已有指定日期的数据 + + 用于断点续传逻辑:如果已有数据则跳过网络请求 + + Args: + code: 股票代码 + target_date: 目标日期(默认今天) + + Returns: + 是否存在数据 + """ + if target_date is None: + target_date = date.today() + + with self.get_session() as session: + result = session.execute( + select(StockDaily).where( + and_( + StockDaily.code == code, + StockDaily.date == target_date + ) + ) + ).scalar_one_or_none() + + return result is not None + + def get_latest_data( + self, + code: str, + days: int = 2 + ) -> List[StockDaily]: + """ + 获取最近 N 天的数据 + + 用于计算"相比昨日"的变化 + + Args: + code: 股票代码 + days: 获取天数 + + Returns: + StockDaily 对象列表(按日期降序) + """ + with self.get_session() as session: + results = session.execute( + select(StockDaily) + .where(StockDaily.code == code) + .order_by(desc(StockDaily.date)) + .limit(days) + ).scalars().all() + + return list(results) + + def get_data_range( + self, + code: str, + start_date: date, + end_date: date + ) -> List[StockDaily]: + """ + 获取指定日期范围的数据 + + Args: + code: 股票代码 + start_date: 开始日期 + end_date: 结束日期 + + Returns: + StockDaily 对象列表 + """ + with self.get_session() as session: + results = session.execute( + select(StockDaily) + .where( + and_( + StockDaily.code == code, + StockDaily.date >= start_date, + StockDaily.date <= end_date + ) + ) + .order_by(StockDaily.date) + ).scalars().all() + + return list(results) + + def save_daily_data( + self, + df: pd.DataFrame, + code: str, + data_source: str = "Unknown" + ) -> int: + """ + 保存日线数据到数据库 + + 策略: + - 使用 UPSERT 逻辑(存在则更新,不存在则插入) + - 跳过已存在的数据,避免重复 + + Args: + df: 包含日线数据的 DataFrame + code: 股票代码 + data_source: 数据来源名称 + + Returns: + 新增/更新的记录数 + """ + if df is None or df.empty: + logger.warning(f"保存数据为空,跳过 {code}") + return 0 + + saved_count = 0 + + with self.get_session() as session: + try: + for _, row in df.iterrows(): + # 解析日期 + row_date = row.get('date') + if isinstance(row_date, str): + row_date = datetime.strptime(row_date, '%Y-%m-%d').date() + elif isinstance(row_date, datetime): + row_date = row_date.date() + elif isinstance(row_date, pd.Timestamp): + row_date = row_date.date() + + # 检查是否已存在 + existing = session.execute( + select(StockDaily).where( + and_( + StockDaily.code == code, + StockDaily.date == row_date + ) + ) + ).scalar_one_or_none() + + if existing: + # 更新现有记录 + existing.open = row.get('open') + existing.high = row.get('high') + existing.low = row.get('low') + existing.close = row.get('close') + existing.volume = row.get('volume') + existing.amount = row.get('amount') + existing.pct_chg = row.get('pct_chg') + existing.ma5 = row.get('ma5') + existing.ma10 = row.get('ma10') + existing.ma20 = row.get('ma20') + existing.volume_ratio = row.get('volume_ratio') + existing.data_source = data_source + existing.updated_at = datetime.now() + else: + # 创建新记录 + record = StockDaily( + code=code, + date=row_date, + open=row.get('open'), + high=row.get('high'), + low=row.get('low'), + close=row.get('close'), + volume=row.get('volume'), + amount=row.get('amount'), + pct_chg=row.get('pct_chg'), + ma5=row.get('ma5'), + ma10=row.get('ma10'), + ma20=row.get('ma20'), + volume_ratio=row.get('volume_ratio'), + data_source=data_source, + ) + session.add(record) + saved_count += 1 + + session.commit() + logger.info(f"保存 {code} 数据成功,新增 {saved_count} 条") + + except Exception as e: + session.rollback() + logger.error(f"保存 {code} 数据失败: {e}") + raise + + return saved_count + + def get_analysis_context( + self, + code: str, + target_date: Optional[date] = None + ) -> Optional[Dict[str, Any]]: + """ + 获取分析所需的上下文数据 + + 返回今日数据 + 昨日数据的对比信息 + + Args: + code: 股票代码 + target_date: 目标日期(默认今天) + + Returns: + 包含今日数据、昨日对比等信息的字典 + """ + if target_date is None: + target_date = date.today() + + # 获取最近2天数据 + recent_data = self.get_latest_data(code, days=2) + + if not recent_data: + logger.warning(f"未找到 {code} 的数据") + return None + + today_data = recent_data[0] + yesterday_data = recent_data[1] if len(recent_data) > 1 else None + + context = { + 'code': code, + 'date': today_data.date.isoformat(), + 'today': today_data.to_dict(), + } + + if yesterday_data: + context['yesterday'] = yesterday_data.to_dict() + + # 计算相比昨日的变化 + if yesterday_data.volume and yesterday_data.volume > 0: + context['volume_change_ratio'] = round( + today_data.volume / yesterday_data.volume, 2 + ) + + if yesterday_data.close and yesterday_data.close > 0: + context['price_change_ratio'] = round( + (today_data.close - yesterday_data.close) / yesterday_data.close * 100, 2 + ) + + # 均线形态判断 + context['ma_status'] = self._analyze_ma_status(today_data) + + return context + + def _analyze_ma_status(self, data: StockDaily) -> str: + """ + 分析均线形态 + + 判断条件: + - 多头排列:close > ma5 > ma10 > ma20 + - 空头排列:close < ma5 < ma10 < ma20 + - 震荡整理:其他情况 + """ + close = data.close or 0 + ma5 = data.ma5 or 0 + ma10 = data.ma10 or 0 + ma20 = data.ma20 or 0 + + if close > ma5 > ma10 > ma20 > 0: + return "多头排列 📈" + elif close < ma5 < ma10 < ma20 and ma20 > 0: + return "空头排列 📉" + elif close > ma5 and ma5 > ma10: + return "短期向好 🔼" + elif close < ma5 and ma5 < ma10: + return "短期走弱 🔽" + else: + return "震荡整理 ↔️" + + +# 便捷函数 +def get_db() -> DatabaseManager: + """获取数据库管理器实例的快捷方式""" + return DatabaseManager.get_instance() + + +if __name__ == "__main__": + # 测试代码 + logging.basicConfig(level=logging.DEBUG) + + db = get_db() + + print("=== 数据库测试 ===") + print(f"数据库初始化成功") + + # 测试检查今日数据 + has_data = db.has_today_data('600519') + print(f"茅台今日是否有数据: {has_data}") + + # 测试保存数据 + test_df = pd.DataFrame({ + 'date': [date.today()], + 'open': [1800.0], + 'high': [1850.0], + 'low': [1780.0], + 'close': [1820.0], + 'volume': [10000000], + 'amount': [18200000000], + 'pct_chg': [1.5], + 'ma5': [1810.0], + 'ma10': [1800.0], + 'ma20': [1790.0], + 'volume_ratio': [1.2], + }) + + saved = db.save_daily_data(test_df, '600519', 'TestSource') + print(f"保存测试数据: {saved} 条") + + # 测试获取上下文 + context = db.get_analysis_context('600519') + print(f"分析上下文: {context}") diff --git a/test_env.py b/test_env.py new file mode 100644 index 000000000..cb97d1b6d --- /dev/null +++ b/test_env.py @@ -0,0 +1,481 @@ +# -*- coding: utf-8 -*- +""" +=================================== +A股自选股智能分析系统 - 环境验证测试 +=================================== + +用于验证 .env 配置是否正确,包括: +1. 配置加载测试 +2. 数据库查看 +3. 数据源测试 +4. LLM 调用测试 +5. 通知推送测试 + +使用方法: + python test_env.py # 运行所有测试 + python test_env.py --db # 仅查看数据库 + python test_env.py --llm # 仅测试 LLM + python test_env.py --fetch # 仅测试数据获取 + python test_env.py --notify # 仅测试通知 + +""" +import os +os.environ["http_proxy"] = "http://127.0.0.1:10809" +os.environ["https_proxy"] = "http://127.0.0.1:10809" + +import argparse +import logging +import sys +from datetime import datetime, date, timedelta +from typing import Optional + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s | %(levelname)-8s | %(message)s', + datefmt='%H:%M:%S' +) +logger = logging.getLogger(__name__) + + +def print_header(title: str): + """打印标题""" + print("\n" + "=" * 60) + print(f" {title}") + print("=" * 60) + + +def print_section(title: str): + """打印小节""" + print(f"\n--- {title} ---") + + +def test_config(): + """测试配置加载""" + print_header("1. 配置加载测试") + + from config import get_config + config = get_config() + + print_section("基础配置") + print(f" 股票列表: {config.stock_list}") + print(f" 数据库路径: {config.database_path}") + print(f" 最大并发数: {config.max_workers}") + print(f" 调试模式: {config.debug}") + + print_section("API 配置") + print(f" Tushare Token: {'已配置 ✓' if config.tushare_token else '未配置 ✗'}") + if config.tushare_token: + print(f" Token 前8位: {config.tushare_token[:8]}...") + + print(f" Gemini API Key: {'已配置 ✓' if config.gemini_api_key else '未配置 ✗'}") + if config.gemini_api_key: + print(f" Key 前8位: {config.gemini_api_key[:8]}...") + print(f" Gemini 主模型: {config.gemini_model}") + print(f" Gemini 备选模型: {config.gemini_model_fallback}") + + print(f" 企业微信 Webhook: {'已配置 ✓' if config.wechat_webhook_url else '未配置 ✗'}") + + print_section("配置验证") + warnings = config.validate() + if warnings: + for w in warnings: + print(f" ⚠ {w}") + else: + print(" ✓ 所有配置项验证通过") + + return True + + +def view_database(): + """查看数据库内容""" + print_header("2. 数据库内容查看") + + from storage import get_db + from sqlalchemy import text + + db = get_db() + + print_section("数据库连接") + print(f" ✓ 连接成功") + + # 使用独立的 session 查询 + session = db.get_session() + try: + # 统计信息 + result = session.execute(text(""" + SELECT + code, + COUNT(*) as count, + MIN(date) as min_date, + MAX(date) as max_date, + data_source + FROM stock_daily + GROUP BY code + ORDER BY code + """)) + stocks = result.fetchall() + + print_section(f"已存储股票数据 (共 {len(stocks)} 只)") + if stocks: + print(f" {'代码':<10} {'记录数':<8} {'起始日期':<12} {'最新日期':<12} {'数据源'}") + print(" " + "-" * 60) + for row in stocks: + print(f" {row[0]:<10} {row[1]:<8} {row[2]!s:<12} {row[3]!s:<12} {row[4] or 'Unknown'}") + else: + print(" 暂无数据") + + # 查询今日数据 + today = date.today() + result = session.execute(text(""" + SELECT code, date, open, high, low, close, pct_chg, volume, ma5, ma10, ma20, volume_ratio + FROM stock_daily + WHERE date = :today + ORDER BY code + """), {"today": today}) + today_data = result.fetchall() + + print_section(f"今日数据 ({today})") + if today_data: + for row in today_data: + code, dt, open_, high, low, close, pct_chg, volume, ma5, ma10, ma20, vol_ratio = row + print(f"\n 【{code}】") + print(f" 开盘: {open_:.2f} 最高: {high:.2f} 最低: {low:.2f} 收盘: {close:.2f}") + print(f" 涨跌幅: {pct_chg:.2f}% 成交量: {volume/10000:.2f}万股") + print(f" MA5: {ma5:.2f} MA10: {ma10:.2f} MA20: {ma20:.2f} 量比: {vol_ratio:.2f}") + else: + print(" 今日暂无数据") + + # 查询最近10条数据 + result = session.execute(text(""" + SELECT code, date, close, pct_chg, volume, data_source + FROM stock_daily + ORDER BY date DESC, code + LIMIT 10 + """)) + recent = result.fetchall() + + print_section("最近10条记录") + if recent: + print(f" {'代码':<10} {'日期':<12} {'收盘':<10} {'涨跌%':<8} {'成交量':<15} {'来源'}") + print(" " + "-" * 70) + for row in recent: + vol_str = f"{row[4]/10000:.2f}万" if row[4] else "N/A" + print(f" {row[0]:<10} {row[1]!s:<12} {row[2]:<10.2f} {row[3]:<8.2f} {vol_str:<15} {row[5] or 'Unknown'}") + finally: + session.close() + + return True + + +def test_data_fetch(stock_code: str = "600519"): + """测试数据获取""" + print_header("3. 数据获取测试") + + from data_provider import DataFetcherManager + + manager = DataFetcherManager() + + print_section("数据源列表") + for i, name in enumerate(manager.available_fetchers, 1): + print(f" {i}. {name}") + + print_section(f"获取 {stock_code} 数据") + print(f" 正在获取(可能需要几秒钟)...") + + try: + df, source = manager.get_daily_data(stock_code, days=5) + + print(f" ✓ 获取成功") + print(f" 数据源: {source}") + print(f" 记录数: {len(df)}") + + print_section("数据预览(最近5条)") + if not df.empty: + preview_cols = ['date', 'open', 'high', 'low', 'close', 'pct_chg', 'volume'] + existing_cols = [c for c in preview_cols if c in df.columns] + print(df[existing_cols].tail().to_string(index=False)) + + return True + + except Exception as e: + print(f" ✗ 获取失败: {e}") + return False + + +def test_llm(): + """测试 LLM 调用""" + print_header("4. LLM (Gemini) 调用测试") + + from analyzer import GeminiAnalyzer + from config import get_config + import time + + config = get_config() + + print_section("模型配置") + print(f" 主模型: {config.gemini_model}") + print(f" 备选模型: {config.gemini_model_fallback}") + + # 检查网络连接 + print_section("网络连接检查") + try: + import socket + socket.setdefaulttimeout(10) + socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect(("generativelanguage.googleapis.com", 443)) + print(f" ✓ 可以连接到 Google API 服务器") + except Exception as e: + print(f" ✗ 无法连接到 Google API 服务器: {e}") + print(f" 提示: 请检查网络连接或配置代理") + print(f" 提示: 可以设置环境变量 HTTPS_PROXY=http://your-proxy:port") + return False + + analyzer = GeminiAnalyzer() + + print_section("模型初始化") + if analyzer.is_available(): + print(f" ✓ 模型初始化成功") + else: + print(f" ✗ 模型初始化失败(请检查 API Key)") + return False + + # 构造测试上下文 + test_context = { + 'code': '600519', + 'date': date.today().isoformat(), + 'today': { + 'open': 1420.0, + 'high': 1435.0, + 'low': 1415.0, + 'close': 1428.0, + 'volume': 5000000, + 'amount': 7140000000, + 'pct_chg': 0.56, + 'ma5': 1425.0, + 'ma10': 1418.0, + 'ma20': 1410.0, + 'volume_ratio': 1.1, + }, + 'ma_status': '多头排列 📈', + 'volume_change_ratio': 1.05, + 'price_change_ratio': 0.56, + } + + print_section("发送测试请求") + print(f" 测试股票: 贵州茅台 (600519)") + print(f" 正在调用 Gemini API(超时: 60秒)...") + + start_time = time.time() + + try: + result = analyzer.analyze(test_context) + + elapsed = time.time() - start_time + print(f"\n ✓ API 调用成功 (耗时: {elapsed:.2f}秒)") + + print_section("分析结果") + print(f" 情绪评分: {result.sentiment_score}/100") + print(f" 趋势预测: {result.trend_prediction}") + print(f" 操作建议: {result.operation_advice}") + print(f" 技术分析: {result.technical_analysis[:80]}..." if len(result.technical_analysis) > 80 else f" 技术分析: {result.technical_analysis}") + print(f" 消息面: {result.news_summary[:80]}..." if len(result.news_summary) > 80 else f" 消息面: {result.news_summary}") + print(f" 综合摘要: {result.analysis_summary}") + + if not result.success: + print(f"\n ⚠ 注意: {result.error_message}") + + return result.success + + except Exception as e: + elapsed = time.time() - start_time + print(f"\n ✗ API 调用失败 (耗时: {elapsed:.2f}秒)") + print(f" 错误: {e}") + + # 提供更详细的错误提示 + error_str = str(e).lower() + if 'timeout' in error_str or 'unavailable' in error_str: + print(f"\n 诊断: 网络超时,可能原因:") + print(f" 1. 网络不通(需要代理访问 Google)") + print(f" 2. API 服务暂时不可用") + print(f" 3. 请求量过大被限流") + elif 'invalid' in error_str or 'api key' in error_str: + print(f"\n 诊断: API Key 可能无效") + elif 'model' in error_str: + print(f"\n 诊断: 模型名称可能不正确,尝试修改 .env 中的 GEMINI_MODEL") + + return False + + +def test_notification(): + """测试通知推送""" + print_header("5. 通知推送测试") + + from notification import NotificationService + from config import get_config + + config = get_config() + service = NotificationService() + + print_section("配置检查") + if service.is_available(): + print(f" ✓ 企业微信 Webhook 已配置") + webhook_preview = config.wechat_webhook_url[:50] + "..." if len(config.wechat_webhook_url) > 50 else config.wechat_webhook_url + print(f" URL: {webhook_preview}") + else: + print(f" ✗ 企业微信 Webhook 未配置") + return False + + print_section("发送测试消息") + + test_message = f"""## 🧪 系统测试消息 + +这是一条来自 **A股自选股智能分析系统** 的测试消息。 + +- 测试时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +- 测试目的: 验证企业微信 Webhook 配置 + +如果您收到此消息,说明通知功能配置正确 ✓""" + + print(f" 正在发送...") + + try: + success = service.send_to_wechat(test_message) + + if success: + print(f" ✓ 消息发送成功,请检查企业微信") + else: + print(f" ✗ 消息发送失败") + + return success + + except Exception as e: + print(f" ✗ 发送异常: {e}") + return False + + +def run_all_tests(): + """运行所有测试""" + print("\n" + "🚀" * 20) + print(" A股自选股智能分析系统 - 环境验证") + print(" " + datetime.now().strftime('%Y-%m-%d %H:%M:%S')) + print("🚀" * 20) + + results = {} + + # 1. 配置测试 + try: + results['配置加载'] = test_config() + except Exception as e: + print(f" ✗ 配置测试失败: {e}") + results['配置加载'] = False + + # 2. 数据库查看 + try: + results['数据库'] = view_database() + except Exception as e: + print(f" ✗ 数据库测试失败: {e}") + results['数据库'] = False + + # 3. 数据获取(跳过,避免太慢) + # results['数据获取'] = test_data_fetch() + + # 4. LLM 测试(可选) + # results['LLM调用'] = test_llm() + + # 汇总 + print_header("测试结果汇总") + for name, passed in results.items(): + status = "✓ 通过" if passed else "✗ 失败" + print(f" {status}: {name}") + + print(f"\n提示: 使用 --llm 参数单独测试 LLM 调用") + print(f"提示: 使用 --fetch 参数单独测试数据获取") + print(f"提示: 使用 --notify 参数单独测试通知推送") + + +def query_stock_data(stock_code: str, days: int = 10): + """查询指定股票的数据""" + print_header(f"查询股票数据: {stock_code}") + + from storage import get_db + from sqlalchemy import text + + db = get_db() + + session = db.get_session() + try: + result = session.execute(text(""" + SELECT date, open, high, low, close, pct_chg, volume, amount, ma5, ma10, ma20, volume_ratio + FROM stock_daily + WHERE code = :code + ORDER BY date DESC + LIMIT :limit + """), {"code": stock_code, "limit": days}) + + rows = result.fetchall() + + if rows: + print(f"\n 最近 {len(rows)} 条记录:\n") + print(f" {'日期':<12} {'开盘':<10} {'最高':<10} {'最低':<10} {'收盘':<10} {'涨跌%':<8} {'MA5':<10} {'MA10':<10} {'量比':<8}") + print(" " + "-" * 100) + for row in rows: + dt, open_, high, low, close, pct_chg, vol, amt, ma5, ma10, ma20, vol_ratio = row + print(f" {dt!s:<12} {open_:<10.2f} {high:<10.2f} {low:<10.2f} {close:<10.2f} {pct_chg:<8.2f} {ma5:<10.2f} {ma10:<10.2f} {vol_ratio:<8.2f}") + else: + print(f" 未找到 {stock_code} 的数据") + finally: + session.close() + + +def main(): + parser = argparse.ArgumentParser( + description='A股自选股智能分析系统 - 环境验证测试', + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + parser.add_argument('--db', action='store_true', help='查看数据库内容') + parser.add_argument('--llm', action='store_true', help='测试 LLM 调用') + parser.add_argument('--fetch', action='store_true', help='测试数据获取') + parser.add_argument('--notify', action='store_true', help='测试通知推送') + parser.add_argument('--config', action='store_true', help='查看配置') + parser.add_argument('--stock', type=str, help='查询指定股票数据,如 --stock 600519') + parser.add_argument('--all', action='store_true', help='运行所有测试(包括 LLM)') + + args = parser.parse_args() + + # 如果没有指定任何参数,运行基础测试 + if not any([args.db, args.llm, args.fetch, args.notify, args.config, args.stock, args.all]): + run_all_tests() + return 0 + + # 根据参数运行指定测试 + if args.config: + test_config() + + if args.db: + view_database() + + if args.stock: + query_stock_data(args.stock) + + if args.fetch: + test_data_fetch() + + if args.llm: + test_llm() + + if args.notify: + test_notification() + + if args.all: + test_config() + view_database() + test_data_fetch() + test_llm() + test_notification() + + return 0 + + +if __name__ == "__main__": + sys.exit(main())