mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 02:43:35 +08:00
Initial commit: A股智能分析系统
This commit is contained in:
30
.dockerignore
Normal file
30
.dockerignore
Normal 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
52
.env.example
Normal 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
6
.github/FUNDING.yml
vendored
Normal 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
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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 获取使用帮助
|
||||
24
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
24
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: 功能建议
|
||||
about: 提出新功能或改进建议
|
||||
title: '[Feature] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## 功能描述
|
||||
简明扼要地描述你希望增加的功能。
|
||||
|
||||
## 使用场景
|
||||
描述在什么情况下需要这个功能。
|
||||
|
||||
## 期望实现
|
||||
描述你期望这个功能如何工作。
|
||||
|
||||
## 备选方案
|
||||
描述你考虑过的其他替代方案。
|
||||
|
||||
## 相关信息
|
||||
- 是否愿意贡献代码实现: [是/否]
|
||||
- 参考链接/文档:
|
||||
- 其他说明:
|
||||
39
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
39
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
## 变更类型
|
||||
|
||||
- [ ] 🐛 Bug 修复
|
||||
- [ ] ✨ 新功能
|
||||
- [ ] 📝 文档更新
|
||||
- [ ] 🎨 代码优化/重构
|
||||
- [ ] ⚡ 性能优化
|
||||
- [ ] 🔧 配置/构建相关
|
||||
|
||||
## 变更描述
|
||||
|
||||
简要描述这个 PR 做了什么。
|
||||
|
||||
## 关联 Issue
|
||||
|
||||
关联的 Issue 编号(如有):fixes #
|
||||
|
||||
## 测试说明
|
||||
|
||||
描述如何测试这些变更:
|
||||
|
||||
1. 步骤一
|
||||
2. 步骤二
|
||||
3. ...
|
||||
|
||||
## 检查清单
|
||||
|
||||
- [ ] 代码符合项目规范
|
||||
- [ ] 已添加必要的注释/文档
|
||||
- [ ] 已在本地测试通过
|
||||
- [ ] 已更新相关文档(如需要)
|
||||
|
||||
## 截图(如适用)
|
||||
|
||||
如有 UI 变更,请附上截图。
|
||||
|
||||
## 其他说明
|
||||
|
||||
其他需要说明的内容。
|
||||
106
.github/workflows/daily_analysis.yml
vendored
Normal file
106
.github/workflows/daily_analysis.yml
vendored
Normal 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
60
.gitignore
vendored
Normal 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
51
CHANGELOG.md
Normal 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
91
CONTRIBUTING.md
Normal 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
430
DEPLOY.md
Normal 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
46
Dockerfile
Normal 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
21
LICENSE
Normal 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
256
README.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# 📈 A股智能分析系统
|
||||
|
||||
[](https://github.com/ZhuLinsen/daily_stock_analysis/stargazers)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://github.com/features/actions)
|
||||
|
||||
> 🤖 基于 AI 大模型的 A 股自选股智能分析系统,每日自动分析并推送「决策仪表盘」到企业微信/钉钉/飞书
|
||||
|
||||

|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
### 🎯 核心功能
|
||||
- **AI 决策仪表盘** - 一句话核心结论 + 精确买卖点位 + 检查清单
|
||||
- **多维度分析** - 技术面 + 筹码分布 + 舆情情报 + 实时行情
|
||||
- **大盘复盘** - 每日市场概览、板块涨跌、北向资金
|
||||
- **定时推送** - 支持企业微信机器人自动推送
|
||||
- **零成本部署** - GitHub Actions 免费运行,无需服务器
|
||||
- **💰 白嫖 Gemini API** - Google AI Studio 提供免费额度,个人使用完全够用
|
||||
|
||||
### 📊 数据来源
|
||||
- **行情数据**: AkShare(免费)、Tushare、Baostock、YFinance
|
||||
- **新闻搜索**: Tavily、SerpAPI
|
||||
- **AI 分析**: Google Gemini(gemini-3-flash-preview)—— [免费获取 API Key](https://aistudio.google.com/)
|
||||
|
||||
### 🛡️ 交易理念内置
|
||||
- ❌ **严禁追高** - 乖离率 > 5% 自动标记「危险」
|
||||
- ✅ **趋势交易** - MA5 > MA10 > MA20 多头排列
|
||||
- 📍 **精确点位** - 买入价、止损价、目标价
|
||||
- 📋 **检查清单** - 每项条件用 ✅⚠️❌ 标记
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 方式一:GitHub Actions(推荐,零成本)
|
||||
|
||||
**无需服务器,每天自动运行!**
|
||||
|
||||
#### 1. Fork 本仓库
|
||||
|
||||
点击右上角 `Fork` 按钮
|
||||
|
||||
#### 2. 配置 Secrets
|
||||
|
||||
进入你 Fork 的仓库 → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`
|
||||
|
||||
| Secret 名称 | 说明 | 必填 |
|
||||
|------------|------|:----:|
|
||||
| `GEMINI_API_KEY` | [Google AI Studio](https://aistudio.google.com/) 获取 | ✅ |
|
||||
| `WECHAT_WEBHOOK_URL` | 企业微信机器人 Webhook | ✅ |
|
||||
| `STOCK_LIST` | 自选股代码,如 `600519,300750,002594` | ✅ |
|
||||
| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) 搜索 API(新闻搜索) | 推荐 |
|
||||
| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/) Key | 可选 |
|
||||
| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/) Token | 可选 |
|
||||
|
||||
#### 3. 启用 Actions
|
||||
|
||||
进入 `Actions` 标签 → 点击 `I understand my workflows, go ahead and enable them`
|
||||
|
||||
#### 4. 手动测试
|
||||
|
||||
`Actions` → `每日股票分析` → `Run workflow` → 选择模式 → `Run workflow`
|
||||
|
||||
#### 5. 完成!
|
||||
|
||||
默认每个工作日 **18:00(北京时间)** 自动执行
|
||||
|
||||
### 方式二:本地运行
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/ZhuLinsen/daily_stock_analysis.git
|
||||
cd daily_stock_analysis
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 配置环境变量
|
||||
cp .env.example .env
|
||||
vim .env # 填入你的 API Key
|
||||
|
||||
# 运行
|
||||
python main.py # 完整分析
|
||||
python main.py --market-review # 仅大盘复盘
|
||||
python main.py --schedule # 定时任务模式
|
||||
```
|
||||
|
||||
### 方式三:Docker 部署
|
||||
|
||||
```bash
|
||||
# 配置环境变量
|
||||
cp .env.example .env
|
||||
vim .env
|
||||
|
||||
# 一键启动
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## 📱 推送效果
|
||||
|
||||
### 决策仪表盘
|
||||
```
|
||||
📊 2026-01-10 决策仪表盘
|
||||
3只股票 | 🟢买入:1 🟡观望:2 🔴卖出:0
|
||||
|
||||
🟢 买入 | 贵州茅台(600519)
|
||||
📌 缩量回踩MA5支撑,乖离率1.2%处于最佳买点
|
||||
💰 狙击: 买入1800 | 止损1750 | 目标1900
|
||||
✅多头排列 ✅乖离安全 ✅量能配合
|
||||
|
||||
🟡 观望 | 宁德时代(300750)
|
||||
📌 乖离率7.8%超过5%警戒线,严禁追高
|
||||
⚠️ 等待回调至MA5附近再考虑
|
||||
|
||||
---
|
||||
生成时间: 18:00
|
||||
```
|
||||
|
||||
### 大盘复盘
|
||||
```
|
||||
🎯 2026-01-10 大盘复盘
|
||||
|
||||
📊 主要指数
|
||||
- 上证指数: 3250.12 (🟢+0.85%)
|
||||
- 深证成指: 10521.36 (🟢+1.02%)
|
||||
- 创业板指: 2156.78 (🟢+1.35%)
|
||||
|
||||
📈 市场概况
|
||||
上涨: 3920 | 下跌: 1349 | 涨停: 155 | 跌停: 3
|
||||
|
||||
🔥 板块表现
|
||||
领涨: 互联网服务、文化传媒、小金属
|
||||
领跌: 保险、航空机场、光伏设备
|
||||
```
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# === 必填 ===
|
||||
GEMINI_API_KEY=your_gemini_key # Gemini AI
|
||||
WECHAT_WEBHOOK_URL=https://qyapi... # 企业微信机器人
|
||||
STOCK_LIST=600519,300750,002594 # 自选股列表
|
||||
|
||||
# === 推荐 ===
|
||||
TAVILY_API_KEYS=your_tavily_key # 新闻搜索
|
||||
GEMINI_MODEL=gemini-3-flash-preview # 主模型
|
||||
GEMINI_MODEL_FALLBACK=gemini-2.5-flash # 备选模型
|
||||
|
||||
# === 可选 ===
|
||||
TUSHARE_TOKEN=your_token # Tushare数据源
|
||||
SERPAPI_API_KEYS=your_serpapi_key # 备用搜索
|
||||
```
|
||||
|
||||
### 定时配置(GitHub Actions)
|
||||
|
||||
编辑 `.github/workflows/daily_analysis.yml`:
|
||||
|
||||
```yaml
|
||||
schedule:
|
||||
# UTC 时间,北京时间 = UTC + 8
|
||||
- cron: '0 10 * * 1-5' # 周一到周五 18:00(北京时间)
|
||||
```
|
||||
|
||||
| 北京时间 | UTC cron |
|
||||
|---------|----------|
|
||||
| 09:30 | `'30 1 * * 1-5'` |
|
||||
| 15:00 | `'0 7 * * 1-5'` |
|
||||
| 18:00 | `'0 10 * * 1-5'` |
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
daily_stock_analysis/
|
||||
├── main.py # 主程序入口
|
||||
├── analyzer.py # AI 分析器(Gemini)
|
||||
├── market_analyzer.py # 大盘复盘分析
|
||||
├── search_service.py # 新闻搜索服务
|
||||
├── notification.py # 消息推送
|
||||
├── scheduler.py # 定时任务
|
||||
├── storage.py # 数据存储
|
||||
├── config.py # 配置管理
|
||||
├── data_provider/ # 数据源适配器
|
||||
│ ├── akshare_fetcher.py
|
||||
│ ├── tushare_fetcher.py
|
||||
│ ├── baostock_fetcher.py
|
||||
│ └── yfinance_fetcher.py
|
||||
├── .github/workflows/ # GitHub Actions
|
||||
├── Dockerfile # Docker 镜像
|
||||
└── docker-compose.yml # Docker 编排
|
||||
```
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
> 📢 以下功能将视后续情况逐步完成,如果你有好的想法或建议,欢迎 [提交 Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues) 讨论!
|
||||
|
||||
### 🔔 通知渠道扩展
|
||||
- [x] 企业微信机器人
|
||||
- [ ] 钉钉机器人
|
||||
- [ ] 飞书机器人
|
||||
- [ ] Telegram Bot
|
||||
- [ ] Discord Webhook
|
||||
- [ ] Slack Webhook
|
||||
- [ ] 邮件通知
|
||||
- [ ] iOS/Android 推送(Bark/Pushover)
|
||||
|
||||
### 🤖 AI 模型支持
|
||||
- [x] Google Gemini
|
||||
- [ ] OpenAI GPT-4
|
||||
- [ ] Claude
|
||||
- [ ] 通义千问
|
||||
- [ ] 文心一言
|
||||
- [ ] DeepSeek
|
||||
- [ ] 本地模型(Ollama)
|
||||
|
||||
### 📊 数据源扩展
|
||||
- [x] AkShare(免费)
|
||||
- [x] Tushare Pro
|
||||
- [x] Baostock
|
||||
- [x] YFinance
|
||||
- [ ] 东方财富 API
|
||||
- [ ] 同花顺 API
|
||||
- [ ] 新浪财经
|
||||
|
||||
### 🎯 功能增强
|
||||
- [x] 决策仪表盘
|
||||
- [x] 大盘复盘
|
||||
- [x] 定时推送
|
||||
- [x] GitHub Actions
|
||||
- [ ] Web 管理界面
|
||||
- [ ] 自选股动态管理 API
|
||||
- [ ] 历史分析回测
|
||||
- [ ] 多策略支持
|
||||
- [ ] 港股/美股支持
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
详见 [贡献指南](CONTRIBUTING.md)
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT License](LICENSE) © 2026 ZhuLinsen
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
本项目仅供学习和研究使用,不构成任何投资建议。股市有风险,投资需谨慎。作者不对使用本项目产生的任何损失负责。
|
||||
|
||||
---
|
||||
|
||||
**如果觉得有用,请给个 ⭐ Star 支持一下!**
|
||||
1074
analyzer.py
Normal file
1074
analyzer.py
Normal file
File diff suppressed because it is too large
Load Diff
217
config.py
Normal file
217
config.py
Normal 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 Key,AI 分析功能将不可用")
|
||||
|
||||
if not self.tavily_api_keys and not self.serpapi_keys:
|
||||
warnings.append("提示:未配置搜索引擎 API Key (Tavily/SerpAPI),新闻搜索功能将不可用")
|
||||
|
||||
if not self.wechat_webhook_url:
|
||||
warnings.append("提示:未配置企业微信 Webhook,将不发送推送通知")
|
||||
|
||||
return warnings
|
||||
|
||||
def get_db_url(self) -> str:
|
||||
"""
|
||||
获取 SQLAlchemy 数据库连接 URL
|
||||
|
||||
自动创建数据库目录(如果不存在)
|
||||
"""
|
||||
db_path = Path(self.database_path)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return f"sqlite:///{db_path.absolute()}"
|
||||
|
||||
|
||||
# === 便捷的配置访问函数 ===
|
||||
def get_config() -> Config:
|
||||
"""获取全局配置实例的快捷方式"""
|
||||
return Config.get_instance()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 测试配置加载
|
||||
config = get_config()
|
||||
print("=== 配置加载测试 ===")
|
||||
print(f"自选股列表: {config.stock_list}")
|
||||
print(f"数据库路径: {config.database_path}")
|
||||
print(f"最大并发数: {config.max_workers}")
|
||||
print(f"调试模式: {config.debug}")
|
||||
|
||||
# 验证配置
|
||||
warnings = config.validate()
|
||||
if warnings:
|
||||
print("\n配置验证结果:")
|
||||
for w in warnings:
|
||||
print(f" - {w}")
|
||||
26
data_provider/__init__.py
Normal file
26
data_provider/__init__.py
Normal 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',
|
||||
]
|
||||
558
data_provider/akshare_fetcher.py
Normal file
558
data_provider/akshare_fetcher.py
Normal 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}")
|
||||
246
data_provider/baostock_fetcher.py
Normal file
246
data_provider/baostock_fetcher.py
Normal 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
351
data_provider/base.py
Normal 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]
|
||||
281
data_provider/tushare_fetcher.py
Normal file
281
data_provider/tushare_fetcher.py
Normal 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: 每分钟最大请求数(默认80,Tushare免费配额)
|
||||
"""
|
||||
self.rate_limit_per_minute = rate_limit_per_minute
|
||||
self._call_count = 0 # 当前分钟内的调用次数
|
||||
self._minute_start: Optional[float] = None # 当前计数周期开始时间
|
||||
self._api: Optional[object] = None # Tushare API 实例
|
||||
|
||||
# 尝试初始化 API
|
||||
self._init_api()
|
||||
|
||||
def _init_api(self) -> None:
|
||||
"""
|
||||
初始化 Tushare API
|
||||
|
||||
如果 Token 未配置,此数据源将不可用
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
if not config.tushare_token:
|
||||
logger.warning("Tushare Token 未配置,此数据源不可用")
|
||||
return
|
||||
|
||||
try:
|
||||
import tushare as ts
|
||||
|
||||
# 设置 Token
|
||||
ts.set_token(config.tushare_token)
|
||||
|
||||
# 获取 API 实例
|
||||
self._api = ts.pro_api()
|
||||
|
||||
logger.info("Tushare API 初始化成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Tushare API 初始化失败: {e}")
|
||||
self._api = None
|
||||
|
||||
def _check_rate_limit(self) -> None:
|
||||
"""
|
||||
检查并执行速率限制
|
||||
|
||||
流控策略:
|
||||
1. 检查是否进入新的一分钟
|
||||
2. 如果是,重置计数器
|
||||
3. 如果当前分钟调用次数超过限制,强制休眠
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
# 检查是否需要重置计数器(新的一分钟)
|
||||
if self._minute_start is None:
|
||||
self._minute_start = current_time
|
||||
self._call_count = 0
|
||||
elif current_time - self._minute_start >= 60:
|
||||
# 已经过了一分钟,重置计数器
|
||||
self._minute_start = current_time
|
||||
self._call_count = 0
|
||||
logger.debug("速率限制计数器已重置")
|
||||
|
||||
# 检查是否超过配额
|
||||
if self._call_count >= self.rate_limit_per_minute:
|
||||
# 计算需要等待的时间(到下一分钟)
|
||||
elapsed = current_time - self._minute_start
|
||||
sleep_time = max(0, 60 - elapsed) + 1 # +1 秒缓冲
|
||||
|
||||
logger.warning(
|
||||
f"Tushare 达到速率限制 ({self._call_count}/{self.rate_limit_per_minute} 次/分钟),"
|
||||
f"等待 {sleep_time:.1f} 秒..."
|
||||
)
|
||||
|
||||
time.sleep(sleep_time)
|
||||
|
||||
# 重置计数器
|
||||
self._minute_start = time.time()
|
||||
self._call_count = 0
|
||||
|
||||
# 增加调用计数
|
||||
self._call_count += 1
|
||||
logger.debug(f"Tushare 当前分钟调用次数: {self._call_count}/{self.rate_limit_per_minute}")
|
||||
|
||||
def _convert_stock_code(self, stock_code: str) -> str:
|
||||
"""
|
||||
转换股票代码为 Tushare 格式
|
||||
|
||||
Tushare 要求的格式:
|
||||
- 沪市:600519.SH
|
||||
- 深市:000001.SZ
|
||||
|
||||
Args:
|
||||
stock_code: 原始代码,如 '600519', '000001'
|
||||
|
||||
Returns:
|
||||
Tushare 格式代码,如 '600519.SH', '000001.SZ'
|
||||
"""
|
||||
code = stock_code.strip()
|
||||
|
||||
# 已经包含后缀的情况
|
||||
if '.' in code:
|
||||
return code.upper()
|
||||
|
||||
# 根据代码前缀判断市场
|
||||
# 沪市:600xxx, 601xxx, 603xxx, 688xxx (科创板)
|
||||
# 深市:000xxx, 002xxx, 300xxx (创业板)
|
||||
if code.startswith(('600', '601', '603', '688')):
|
||||
return f"{code}.SH"
|
||||
elif code.startswith(('000', '002', '300')):
|
||||
return f"{code}.SZ"
|
||||
else:
|
||||
# 默认尝试深市
|
||||
logger.warning(f"无法确定股票 {code} 的市场,默认使用深市")
|
||||
return f"{code}.SZ"
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=2, max=30),
|
||||
retry=retry_if_exception_type((ConnectionError, TimeoutError)),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
)
|
||||
def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
|
||||
"""
|
||||
从 Tushare 获取原始数据
|
||||
|
||||
使用 daily() 接口获取日线数据
|
||||
|
||||
流程:
|
||||
1. 检查 API 是否可用
|
||||
2. 执行速率限制检查
|
||||
3. 转换股票代码格式
|
||||
4. 调用 API 获取数据
|
||||
"""
|
||||
if self._api is None:
|
||||
raise DataFetchError("Tushare API 未初始化,请检查 Token 配置")
|
||||
|
||||
# 速率限制检查
|
||||
self._check_rate_limit()
|
||||
|
||||
# 转换代码格式
|
||||
ts_code = self._convert_stock_code(stock_code)
|
||||
|
||||
# 转换日期格式(Tushare 要求 YYYYMMDD)
|
||||
ts_start = start_date.replace('-', '')
|
||||
ts_end = end_date.replace('-', '')
|
||||
|
||||
logger.debug(f"调用 Tushare daily({ts_code}, {ts_start}, {ts_end})")
|
||||
|
||||
try:
|
||||
# 调用 daily 接口获取日线数据
|
||||
df = self._api.daily(
|
||||
ts_code=ts_code,
|
||||
start_date=ts_start,
|
||||
end_date=ts_end,
|
||||
)
|
||||
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
|
||||
# 检测配额超限
|
||||
if any(keyword in error_msg for keyword in ['quota', '配额', 'limit', '权限']):
|
||||
logger.warning(f"Tushare 配额可能超限: {e}")
|
||||
raise RateLimitError(f"Tushare 配额超限: {e}") from e
|
||||
|
||||
raise DataFetchError(f"Tushare 获取数据失败: {e}") from e
|
||||
|
||||
def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
|
||||
"""
|
||||
标准化 Tushare 数据
|
||||
|
||||
Tushare daily 返回的列名:
|
||||
ts_code, trade_date, open, high, low, close, pre_close, change, pct_chg, vol, amount
|
||||
|
||||
需要映射到标准列名:
|
||||
date, open, high, low, close, volume, amount, pct_chg
|
||||
"""
|
||||
df = df.copy()
|
||||
|
||||
# 列名映射
|
||||
column_mapping = {
|
||||
'trade_date': 'date',
|
||||
'vol': 'volume',
|
||||
# open, high, low, close, amount, pct_chg 列名相同
|
||||
}
|
||||
|
||||
df = df.rename(columns=column_mapping)
|
||||
|
||||
# 转换日期格式(YYYYMMDD -> YYYY-MM-DD)
|
||||
if 'date' in df.columns:
|
||||
df['date'] = pd.to_datetime(df['date'], format='%Y%m%d')
|
||||
|
||||
# 成交量单位转换(Tushare 的 vol 单位是手,需要转换为股)
|
||||
if 'volume' in df.columns:
|
||||
df['volume'] = df['volume'] * 100
|
||||
|
||||
# 成交额单位转换(Tushare 的 amount 单位是千元,转换为元)
|
||||
if 'amount' in df.columns:
|
||||
df['amount'] = df['amount'] * 1000
|
||||
|
||||
# 添加股票代码列
|
||||
df['code'] = stock_code
|
||||
|
||||
# 只保留需要的列
|
||||
keep_cols = ['code'] + STANDARD_COLUMNS
|
||||
existing_cols = [col for col in keep_cols if col in df.columns]
|
||||
df = df[existing_cols]
|
||||
|
||||
return df
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 测试代码
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
fetcher = TushareFetcher()
|
||||
|
||||
try:
|
||||
df = fetcher.get_daily_data('600519') # 茅台
|
||||
print(f"获取成功,共 {len(df)} 条数据")
|
||||
print(df.tail())
|
||||
except Exception as e:
|
||||
print(f"获取失败: {e}")
|
||||
197
data_provider/yfinance_fetcher.py
Normal file
197
data_provider/yfinance_fetcher.py
Normal 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
53
docker-compose.yml
Normal 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
845
main.py
Normal 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
520
market_analyzer.py
Normal 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
1010
notification.py
Normal file
File diff suppressed because it is too large
Load Diff
33
requirements.txt
Normal file
33
requirements.txt
Normal 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
184
scheduler.py
Normal 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
696
search_service.py
Normal 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
BIN
sources/sample.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
535
stock_analyzer.py
Normal file
535
stock_analyzer.py
Normal 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
508
storage.py
Normal 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
481
test_env.py
Normal 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())
|
||||
Reference in New Issue
Block a user