From 4a003c94ea7047dcb52e5b767d7c42f62b7a533f Mon Sep 17 00:00:00 2001 From: zhulinsen Date: Sat, 10 Jan 2026 15:38:56 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20A=E8=82=A1=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E5=88=86=E6=9E=90=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 30 + .env.example | 52 + .github/FUNDING.yml | 6 + .github/ISSUE_TEMPLATE/bug_report.md | 38 + .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.md | 24 + .github/PULL_REQUEST_TEMPLATE.md | 39 + .github/workflows/daily_analysis.yml | 106 ++ .gitignore | 60 ++ CHANGELOG.md | 51 + CONTRIBUTING.md | 91 ++ DEPLOY.md | 430 +++++++++ Dockerfile | 46 + LICENSE | 21 + README.md | 256 +++++ analyzer.py | 1074 +++++++++++++++++++++ config.py | 217 +++++ data_provider/__init__.py | 26 + data_provider/akshare_fetcher.py | 558 +++++++++++ data_provider/baostock_fetcher.py | 246 +++++ data_provider/base.py | 351 +++++++ data_provider/tushare_fetcher.py | 281 ++++++ data_provider/yfinance_fetcher.py | 197 ++++ docker-compose.yml | 53 + main.py | 845 ++++++++++++++++ market_analyzer.py | 520 ++++++++++ notification.py | 1010 +++++++++++++++++++ requirements.txt | 33 + scheduler.py | 184 ++++ search_service.py | 696 +++++++++++++ sources/sample.png | Bin 0 -> 88306 bytes stock_analyzer.py | 535 ++++++++++ storage.py | 508 ++++++++++ test_env.py | 481 +++++++++ 34 files changed, 9073 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/daily_analysis.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 DEPLOY.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 analyzer.py create mode 100644 config.py create mode 100644 data_provider/__init__.py create mode 100644 data_provider/akshare_fetcher.py create mode 100644 data_provider/baostock_fetcher.py create mode 100644 data_provider/base.py create mode 100644 data_provider/tushare_fetcher.py create mode 100644 data_provider/yfinance_fetcher.py create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 market_analyzer.py create mode 100644 notification.py create mode 100644 requirements.txt create mode 100644 scheduler.py create mode 100644 search_service.py create mode 100644 sources/sample.png create mode 100644 stock_analyzer.py create mode 100644 storage.py create mode 100644 test_env.py 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 0000000000000000000000000000000000000000..6e06ca01f7b17ff94fb5a8009accfb76d5bb251b GIT binary patch literal 88306 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 literal 0 HcmV?d00001 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())