Initial commit: A股智能分析系统

This commit is contained in:
zhulinsen
2026-01-10 15:38:56 +08:00
commit 4a003c94ea
34 changed files with 9073 additions and 0 deletions

30
.dockerignore Normal file
View File

@@ -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

52
.env.example Normal file
View File

@@ -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

6
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
# 如果你想支持这个项目,可以通过以下方式:
# github: [ZhuLinsen]
# patreon:
# open_collective:
# ko_fi:
# custom: ['https://example.com/donate']

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -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:
- 数据源:
## 其他信息
添加任何其他有关问题的信息或截图。

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -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 获取使用帮助

View File

@@ -0,0 +1,24 @@
---
name: 功能建议
about: 提出新功能或改进建议
title: '[Feature] '
labels: enhancement
assignees: ''
---
## 功能描述
简明扼要地描述你希望增加的功能。
## 使用场景
描述在什么情况下需要这个功能。
## 期望实现
描述你期望这个功能如何工作。
## 备选方案
描述你考虑过的其他替代方案。
## 相关信息
- 是否愿意贡献代码实现: [是/否]
- 参考链接/文档:
- 其他说明:

39
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,39 @@
## 变更类型
- [ ] 🐛 Bug 修复
- [ ] ✨ 新功能
- [ ] 📝 文档更新
- [ ] 🎨 代码优化/重构
- [ ] ⚡ 性能优化
- [ ] 🔧 配置/构建相关
## 变更描述
简要描述这个 PR 做了什么。
## 关联 Issue
关联的 Issue 编号如有fixes #
## 测试说明
描述如何测试这些变更:
1. 步骤一
2. 步骤二
3. ...
## 检查清单
- [ ] 代码符合项目规范
- [ ] 已添加必要的注释/文档
- [ ] 已在本地测试通过
- [ ] 已更新相关文档(如需要)
## 截图(如适用)
如有 UI 变更,请附上截图。
## 其他说明
其他需要说明的内容。

106
.github/workflows/daily_analysis.yml vendored Normal file
View File

@@ -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

60
.gitignore vendored Normal file
View File

@@ -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/

51
CHANGELOG.md Normal file
View File

@@ -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

91
CONTRIBUTING.md Normal file
View File

@@ -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
再次感谢你的贡献! 🎉

430
DEPLOY.md Normal file
View File

@@ -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 <your-repo-url> /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 <your-repo-url> .
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 分钟限制。
---
**祝部署顺利!🎉**

46
Dockerfile Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

256
README.md Normal file
View File

@@ -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 Geminigemini-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 支持一下!**

1074
analyzer.py Normal file

File diff suppressed because it is too large Load Diff

217
config.py Normal file
View File

@@ -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 KeyAI 分析功能将不可用")
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}")

26
data_provider/__init__.py Normal file
View File

@@ -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',
]

View File

@@ -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}")

View File

@@ -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}")

351
data_provider/base.py Normal file
View File

@@ -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]

View File

@@ -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: 每分钟最大请求数默认80Tushare免费配额
"""
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}")

View File

@@ -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}")

53
docker-compose.yml Normal file
View File

@@ -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

845
main.py Normal file
View File

@@ -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())

520
market_analyzer.py Normal file
View File

@@ -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)

1010
notification.py Normal file

File diff suppressed because it is too large Load Diff

33
requirements.txt Normal file
View File

@@ -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 内置,无需额外安装

184
scheduler.py Normal file
View File

@@ -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)

696
search_service.py Normal file
View File

@@ -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跳过测试")

BIN
sources/sample.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

535
stock_analyzer.py Normal file
View File

@@ -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<MA10<MA20"
result.trend_strength = 25
elif ma5 < ma10 and ma10 >= ma20:
result.trend_status = TrendStatus.WEAK_BEAR
result.ma_alignment = "弱势空头MA5<MA10 但 MA10≥MA20"
result.trend_strength = 40
else:
result.trend_status = TrendStatus.CONSOLIDATION
result.ma_alignment = "均线缠绕,趋势不明"
result.trend_strength = 50
def _calculate_bias(self, result: TrendAnalysisResult) -> 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))

508
storage.py Normal file
View File

@@ -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"<StockDaily(code={self.code}, date={self.date}, close={self.close})>"
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}")

481
test_env.py Normal file
View File

@@ -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())