mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
Merge upstream/main into feat/discord-bot
Resolve conflicts in config.py and notification.py: - config.py: Merge realtime quote configuration and Discord bot status - notification.py: Add markdown2 import and enhance single stock report with risk alerts, catalysts, and operation points
This commit is contained in:
134
.github/workflows/daily_analysis.yml
vendored
134
.github/workflows/daily_analysis.yml
vendored
@@ -18,9 +18,16 @@ on:
|
||||
- market-only # 仅大盘复盘
|
||||
- stocks-only # 仅股票分析
|
||||
|
||||
# 并发控制:同一时间只运行一个分析任务
|
||||
concurrency:
|
||||
group: stock-analysis
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
# 添加超时限制,防止任务卡死
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
@@ -43,69 +50,126 @@ jobs:
|
||||
|
||||
- name: 执行股票分析
|
||||
env:
|
||||
# Gemini AI
|
||||
# ==========================================
|
||||
# AI 配置
|
||||
# ==========================================
|
||||
# Gemini AI(主选)
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GEMINI_MODEL: ${{ vars.GEMINI_MODEL || secrets.GEMINI_MODEL || 'gemini-3-flash-preview' }}
|
||||
GEMINI_MODEL_FALLBACK: ${{ vars.GEMINI_MODEL_FALLBACK || secrets.GEMINI_MODEL_FALLBACK || 'gemini-2.5-flash' }}
|
||||
GEMINI_REQUEST_DELAY: '3.0' # GitHub Actions 建议增加延时
|
||||
|
||||
# 数据源 (可选)
|
||||
TUSHARE_TOKEN: ${{ secrets.TUSHARE_TOKEN }}
|
||||
|
||||
# 搜索服务
|
||||
BOCHA_API_KEYS: ${{ secrets.BOCHA_API_KEYS }}
|
||||
TAVILY_API_KEYS: ${{ secrets.TAVILY_API_KEYS }}
|
||||
SERPAPI_API_KEYS: ${{ secrets.SERPAPI_API_KEYS }}
|
||||
GEMINI_MODEL: ${{ vars.GEMINI_MODEL || secrets.GEMINI_MODEL || 'gemini-2.5-flash' }}
|
||||
GEMINI_MODEL_FALLBACK: ${{ vars.GEMINI_MODEL_FALLBACK || secrets.GEMINI_MODEL_FALLBACK || 'gemini-2.0-flash' }}
|
||||
GEMINI_REQUEST_DELAY: '3.0'
|
||||
|
||||
# OpenAI 兼容 API(备选)
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL || secrets.OPENAI_BASE_URL }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI_MODEL || secrets.OPENAI_MODEL }}
|
||||
|
||||
# ==========================================
|
||||
# 数据源
|
||||
# ==========================================
|
||||
TUSHARE_TOKEN: ${{ secrets.TUSHARE_TOKEN }}
|
||||
|
||||
# ==========================================
|
||||
# 搜索服务
|
||||
# ==========================================
|
||||
BOCHA_API_KEYS: ${{ secrets.BOCHA_API_KEYS }}
|
||||
TAVILY_API_KEYS: ${{ secrets.TAVILY_API_KEYS }}
|
||||
SERPAPI_API_KEYS: ${{ secrets.SERPAPI_API_KEYS }}
|
||||
|
||||
# ==========================================
|
||||
# 通知渠道(可同时配置多个,全部推送)
|
||||
# 方式一:企业微信
|
||||
# ==========================================
|
||||
|
||||
# 方式一:企业微信 Webhook
|
||||
WECHAT_WEBHOOK_URL: ${{ secrets.WECHAT_WEBHOOK_URL }}
|
||||
# 方式二:飞书
|
||||
|
||||
# 方式二:飞书 Webhook
|
||||
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
|
||||
# 方式三:Telegram(需同时配置 Bot Token 和 Chat ID)
|
||||
|
||||
# 方式三:Telegram
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
# 方式四:邮件(只需邮箱和授权码,SMTP自动识别)
|
||||
|
||||
# 方式四:邮件
|
||||
EMAIL_SENDER: ${{ vars.EMAIL_SENDER || secrets.EMAIL_SENDER }}
|
||||
EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }}
|
||||
EMAIL_RECEIVERS: ${{ vars.EMAIL_RECEIVERS || secrets.EMAIL_RECEIVERS }}
|
||||
# 方式五:自定义 Webhook(支持钉钉、Discord、Slack、Bark等,多个用逗号分隔)
|
||||
|
||||
# 方式五:Pushover
|
||||
PUSHOVER_USER_KEY: ${{ secrets.PUSHOVER_USER_KEY }}
|
||||
PUSHOVER_API_TOKEN: ${{ secrets.PUSHOVER_API_TOKEN }}
|
||||
|
||||
# 方式六:PushPlus ⬅️ 新增!
|
||||
PUSHPLUS_TOKEN: ${{ secrets.PUSHPLUS_TOKEN }}
|
||||
|
||||
# 方式七:自定义 Webhook(钉钉、Bark、自建服务等)
|
||||
CUSTOM_WEBHOOK_URLS: ${{ secrets.CUSTOM_WEBHOOK_URLS }}
|
||||
CUSTOM_WEBHOOK_BEARER_TOKEN: ${{ secrets.CUSTOM_WEBHOOK_BEARER_TOKEN }}
|
||||
# 方式六:Discord(支持 Webhook 和 Bot API)
|
||||
|
||||
# 方式八:Discord
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
|
||||
DISCORD_MAIN_CHANNEL_ID: ${{ secrets.DISCORD_MAIN_CHANNEL_ID }}
|
||||
# 方式七:飞书文档
|
||||
|
||||
# 方式九:飞书云文档
|
||||
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
|
||||
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
|
||||
FEISHU_FOLDER_TOKEN: ${{ secrets.FEISHU_FOLDER_TOKEN }}
|
||||
|
||||
|
||||
# 自选股列表 (从 secrets 或使用默认值)
|
||||
# ==========================================
|
||||
# 自选股配置
|
||||
# ==========================================
|
||||
STOCK_LIST: ${{ vars.STOCK_LIST || secrets.STOCK_LIST || '600519' }}
|
||||
|
||||
# 其他配置
|
||||
# ==========================================
|
||||
# 运行配置 ⬅️ 新增!
|
||||
# ==========================================
|
||||
REPORT_TYPE: ${{ vars.REPORT_TYPE || secrets.REPORT_TYPE || 'simple' }}
|
||||
SINGLE_STOCK_NOTIFY: ${{ vars.SINGLE_STOCK_NOTIFY || secrets.SINGLE_STOCK_NOTIFY || 'false' }}
|
||||
MARKET_REVIEW_ENABLED: ${{ vars.MARKET_REVIEW_ENABLED || secrets.MARKET_REVIEW_ENABLED || 'true' }}
|
||||
ANALYSIS_DELAY: ${{ vars.ANALYSIS_DELAY || secrets.ANALYSIS_DELAY || '0' }}
|
||||
|
||||
# ==========================================
|
||||
# 系统配置
|
||||
# ==========================================
|
||||
LOG_LEVEL: INFO
|
||||
DATA_DAYS: 60
|
||||
MAX_CONCURRENT: 3
|
||||
MAX_WORKERS: 3
|
||||
# GitHub Actions 环境建议关闭不稳定的数据源
|
||||
ENABLE_CHIP_DISTRIBUTION: 'false'
|
||||
|
||||
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 "BOCHA_API_KEYS 是否配置: $([ -n "$BOCHA_API_KEYS" ] && echo '是' || echo '否')"
|
||||
echo "BOCHA_API_KEYS 长度: ${#BOCHA_API_KEYS}"
|
||||
echo "🚀 A股自选股智能分析系统"
|
||||
echo "=========================================="
|
||||
echo "⏰ 时间: $(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "🎯 运行模式: $MODE"
|
||||
echo "📊 自选股: $STOCK_LIST"
|
||||
echo "📝 报告类型: $REPORT_TYPE"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "📋 配置检查"
|
||||
echo "=========================================="
|
||||
echo "【AI 配置】"
|
||||
echo " Gemini API Key: $([ -n "$GEMINI_API_KEY" ] && echo '✅ 已配置' || echo '❌ 未配置')"
|
||||
echo " OpenAI API Key: $([ -n "$OPENAI_API_KEY" ] && echo '✅ 已配置' || echo '⚪ 未配置')"
|
||||
echo ""
|
||||
echo "【搜索引擎】"
|
||||
echo " Bocha API Keys: $([ -n "$BOCHA_API_KEYS" ] && echo '✅ 已配置' || echo '⚪ 未配置')"
|
||||
echo " Tavily API Keys: $([ -n "$TAVILY_API_KEYS" ] && echo '✅ 已配置' || echo '⚪ 未配置')"
|
||||
echo ""
|
||||
echo "【通知渠道】"
|
||||
echo " PushPlus: $([ -n "$PUSHPLUS_TOKEN" ] && echo '✅ 已配置' || echo '⚪ 未配置')"
|
||||
echo " 企业微信: $([ -n "$WECHAT_WEBHOOK_URL" ] && echo '✅ 已配置' || echo '⚪ 未配置')"
|
||||
echo " 飞书: $([ -n "$FEISHU_WEBHOOK_URL" ] && echo '✅ 已配置' || echo '⚪ 未配置')"
|
||||
echo " Telegram: $([ -n "$TELEGRAM_BOT_TOKEN" ] && echo '✅ 已配置' || echo '⚪ 未配置')"
|
||||
echo " Discord: $([ -n "$DISCORD_WEBHOOK_URL" ] && echo '✅ 已配置' || echo '⚪ 未配置')"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# 执行分析
|
||||
if [ "$MODE" = "market-only" ]; then
|
||||
python main.py --market-review
|
||||
elif [ "$MODE" = "stocks-only" ]; then
|
||||
@@ -127,10 +191,18 @@ jobs:
|
||||
- name: 显示运行结果
|
||||
if: always()
|
||||
run: |
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "分析完成"
|
||||
echo "📊 分析完成"
|
||||
echo "=========================================="
|
||||
if [ -d "reports" ]; then
|
||||
if [ -d "reports" ] && [ "$(ls -A reports 2>/dev/null)" ]; then
|
||||
echo "生成的报告:"
|
||||
ls -la reports/
|
||||
else
|
||||
echo "⚠️ 未生成报告文件"
|
||||
fi
|
||||
echo ""
|
||||
if [ -f "logs/stock_analysis_$(date +%Y%m%d).log" ]; then
|
||||
echo "📜 最近日志(最后 30 行):"
|
||||
tail -30 logs/stock_analysis_*.log 2>/dev/null || echo "无日志"
|
||||
fi
|
||||
|
||||
30
config.py
30
config.py
@@ -121,8 +121,23 @@ class Config:
|
||||
schedule_enabled: bool = False # 是否启用定时任务
|
||||
schedule_time: str = "18:00" # 每日推送时间(HH:MM 格式)
|
||||
market_review_enabled: bool = True # 是否启用大盘复盘
|
||||
|
||||
# === 流控配置(防封禁关键参数)===
|
||||
|
||||
# === 实时行情增强数据配置 ===
|
||||
# 实时行情开关(关闭后使用历史收盘价进行分析)
|
||||
enable_realtime_quote: bool = True
|
||||
# 筹码分布开关(该接口不稳定,云端部署建议关闭)
|
||||
enable_chip_distribution: bool = True
|
||||
# 实时行情数据源优先级(逗号分隔)
|
||||
realtime_source_priority: str = "akshare_sina,tencent,efinance,akshare_em"
|
||||
# 实时行情缓存时间(秒)
|
||||
realtime_cache_ttl: int = 600
|
||||
# 熔断器冷却时间(秒)
|
||||
circuit_breaker_cooldown: int = 300
|
||||
|
||||
# Discord 机器人状态
|
||||
discord_bot_status: str = "A股智能分析 | /help"
|
||||
|
||||
# === 流控配置(防封禁关键参数)===
|
||||
# Akshare 请求间隔范围(秒)
|
||||
akshare_sleep_min: float = 2.0
|
||||
akshare_sleep_max: float = 5.0
|
||||
@@ -293,7 +308,16 @@ class Config:
|
||||
# Telegram
|
||||
telegram_webhook_secret=os.getenv('TELEGRAM_WEBHOOK_SECRET'),
|
||||
# Discord 机器人扩展配置
|
||||
discord_bot_status=os.getenv('DISCORD_BOT_STATUS', 'A股智能分析 | /help')
|
||||
discord_bot_status=os.getenv('DISCORD_BOT_STATUS', 'A股智能分析 | /help'),
|
||||
# 实时行情增强数据配置
|
||||
enable_realtime_quote=os.getenv('ENABLE_REALTIME_QUOTE', 'true').lower() == 'true',
|
||||
enable_chip_distribution=os.getenv('ENABLE_CHIP_DISTRIBUTION', 'true').lower() == 'true',
|
||||
# 实时行情数据源优先级:
|
||||
# - akshare_sina/tencent: 单股票直连查询,轻量级,推荐放前面
|
||||
# - efinance/akshare_em: 全量拉取,数据丰富但负载大
|
||||
realtime_source_priority=os.getenv('REALTIME_SOURCE_PRIORITY', 'akshare_sina,tencent,efinance,akshare_em'),
|
||||
realtime_cache_ttl=int(os.getenv('REALTIME_CACHE_TTL', '600')),
|
||||
circuit_breaker_cooldown=int(os.getenv('CIRCUIT_BREAKER_COOLDOWN', '300'))
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
AkshareFetcher - 主数据源 (Priority 1)
|
||||
===================================
|
||||
|
||||
数据来源:东方财富爬虫(通过 akshare 库)
|
||||
数据来源:
|
||||
1. 东方财富爬虫(通过 akshare 库) - 默认数据源
|
||||
2. 新浪财经接口 - 备选数据源
|
||||
3. 腾讯财经接口 - 备选数据源
|
||||
|
||||
特点:免费、无需 Token、数据全面
|
||||
风险:爬虫机制易被反爬封禁
|
||||
|
||||
@@ -12,6 +16,7 @@ AkshareFetcher - 主数据源 (Priority 1)
|
||||
1. 每次请求前随机休眠 2-5 秒
|
||||
2. 随机轮换 User-Agent
|
||||
3. 使用 tenacity 实现指数退避重试
|
||||
4. 熔断器机制:连续失败后自动冷却
|
||||
|
||||
增强数据:
|
||||
- 实时行情:量比、换手率、市盈率、市净率、总市值、流通市值
|
||||
@@ -23,7 +28,7 @@ import random
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
import pandas as pd
|
||||
from tenacity import (
|
||||
@@ -35,139 +40,17 @@ from tenacity import (
|
||||
)
|
||||
|
||||
from .base import BaseFetcher, DataFetchError, RateLimitError, STANDARD_COLUMNS
|
||||
from .realtime_types import (
|
||||
UnifiedRealtimeQuote, ChipDistribution, RealtimeSource,
|
||||
get_realtime_circuit_breaker, get_chip_circuit_breaker,
|
||||
safe_float, safe_int # 使用统一的类型转换函数
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
# 保留旧的 RealtimeQuote 别名,用于向后兼容
|
||||
RealtimeQuote = UnifiedRealtimeQuote
|
||||
|
||||
|
||||
@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__)
|
||||
|
||||
|
||||
@@ -182,17 +65,21 @@ USER_AGENTS = [
|
||||
|
||||
|
||||
# 缓存实时行情数据(避免重复请求)
|
||||
# TTL 设为 20 分钟 (1200秒):
|
||||
# - 批量分析场景:通常 30 只股票在 5 分钟内分析完,20 分钟足够覆盖
|
||||
# - 实时性要求:股票分析不需要秒级实时数据,20 分钟延迟可接受
|
||||
# - 防封禁:减少 API 调用频率
|
||||
_realtime_cache: Dict[str, Any] = {
|
||||
'data': None,
|
||||
'timestamp': 0,
|
||||
'ttl': 60 # 60秒缓存有效期
|
||||
'ttl': 1200 # 20分钟缓存有效期
|
||||
}
|
||||
|
||||
# ETF 实时行情缓存
|
||||
_etf_realtime_cache: Dict[str, Any] = {
|
||||
'data': None,
|
||||
'timestamp': 0,
|
||||
'ttl': 60 # 60秒缓存有效期
|
||||
'ttl': 1200 # 20分钟缓存有效期
|
||||
}
|
||||
|
||||
|
||||
@@ -581,22 +468,30 @@ class AkshareFetcher(BaseFetcher):
|
||||
|
||||
return df
|
||||
|
||||
def get_realtime_quote(self, stock_code: str) -> Optional[RealtimeQuote]:
|
||||
def get_realtime_quote(self, stock_code: str, source: str = "em") -> Optional[UnifiedRealtimeQuote]:
|
||||
"""
|
||||
获取实时行情数据
|
||||
获取实时行情数据(支持多数据源)
|
||||
|
||||
根据代码类型自动选择数据源:
|
||||
- 普通股票:ak.stock_zh_a_spot_em()
|
||||
- ETF 基金:ak.fund_etf_spot_em()
|
||||
- 港股:ak.stock_hk_spot_em()
|
||||
- 美股:不支持,返回 None(由 YfinanceFetcher 处理)
|
||||
数据源优先级(可配置):
|
||||
1. em: 东方财富(akshare ak.stock_zh_a_spot_em)- 数据最全,含量比/PE/PB/市值等
|
||||
2. sina: 新浪财经(akshare ak.stock_zh_a_spot)- 轻量级,基本行情
|
||||
3. tencent: 腾讯直连接口 - 单股票查询,负载小
|
||||
|
||||
Args:
|
||||
stock_code: 股票/ETF代码
|
||||
source: 数据源类型,可选 "em", "sina", "tencent"
|
||||
|
||||
Returns:
|
||||
RealtimeQuote 对象,获取失败返回 None
|
||||
UnifiedRealtimeQuote 对象,获取失败返回 None
|
||||
"""
|
||||
# 检查熔断器状态
|
||||
circuit_breaker = get_realtime_circuit_breaker()
|
||||
source_key = f"akshare_{source}"
|
||||
|
||||
if not circuit_breaker.is_available(source_key):
|
||||
logger.warning(f"[熔断] 数据源 {source_key} 处于熔断状态,跳过")
|
||||
return None
|
||||
|
||||
# 根据代码类型选择不同的获取方法
|
||||
if _is_us_code(stock_code):
|
||||
# 美股不使用 Akshare,由 YfinanceFetcher 处理
|
||||
@@ -607,16 +502,25 @@ class AkshareFetcher(BaseFetcher):
|
||||
elif _is_etf_code(stock_code):
|
||||
return self._get_etf_realtime_quote(stock_code)
|
||||
else:
|
||||
return self._get_stock_realtime_quote(stock_code)
|
||||
# 普通 A 股:根据 source 选择数据源
|
||||
if source == "sina":
|
||||
return self._get_stock_realtime_quote_sina(stock_code)
|
||||
elif source == "tencent":
|
||||
return self._get_stock_realtime_quote_tencent(stock_code)
|
||||
else:
|
||||
return self._get_stock_realtime_quote_em(stock_code)
|
||||
|
||||
def _get_stock_realtime_quote(self, stock_code: str) -> Optional[RealtimeQuote]:
|
||||
def _get_stock_realtime_quote_em(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]:
|
||||
"""
|
||||
获取普通 A 股实时行情数据
|
||||
获取普通 A 股实时行情数据(东方财富数据源)
|
||||
|
||||
数据来源:ak.stock_zh_a_spot_em()
|
||||
包含:量比、换手率、市盈率、市净率、总市值、流通市值等
|
||||
优点:数据最全,含量比、换手率、市盈率、市净率、总市值、流通市值等
|
||||
缺点:全量拉取,数据量大,容易超时/限流
|
||||
"""
|
||||
import akshare as ak
|
||||
circuit_breaker = get_realtime_circuit_breaker()
|
||||
source_key = "akshare_em"
|
||||
|
||||
try:
|
||||
# 检查缓存
|
||||
@@ -624,8 +528,11 @@ class AkshareFetcher(BaseFetcher):
|
||||
if (_realtime_cache['data'] is not None and
|
||||
current_time - _realtime_cache['timestamp'] < _realtime_cache['ttl']):
|
||||
df = _realtime_cache['data']
|
||||
logger.debug(f"[缓存命中] 使用缓存的A股实时行情数据")
|
||||
cache_age = int(current_time - _realtime_cache['timestamp'])
|
||||
logger.debug(f"[缓存命中] A股实时行情(东财) - 缓存年龄 {cache_age}s/{_realtime_cache['ttl']}s")
|
||||
else:
|
||||
# 触发全量刷新
|
||||
logger.info(f"[缓存未命中] 触发全量刷新 A股实时行情(东财)")
|
||||
last_error: Optional[Exception] = None
|
||||
df = None
|
||||
for attempt in range(1, 3):
|
||||
@@ -642,6 +549,7 @@ class AkshareFetcher(BaseFetcher):
|
||||
|
||||
api_elapsed = _time.time() - api_start
|
||||
logger.info(f"[API返回] ak.stock_zh_a_spot_em 成功: 返回 {len(df)} 只股票, 耗时 {api_elapsed:.2f}s")
|
||||
circuit_breaker.record_success(source_key)
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
@@ -651,9 +559,11 @@ class AkshareFetcher(BaseFetcher):
|
||||
# 更新缓存:成功缓存数据;失败也缓存空数据,避免同一轮任务对同一接口反复请求
|
||||
if df is None:
|
||||
logger.error(f"[API错误] ak.stock_zh_a_spot_em 最终失败: {last_error}")
|
||||
circuit_breaker.record_failure(source_key, str(last_error))
|
||||
df = pd.DataFrame()
|
||||
_realtime_cache['data'] = df
|
||||
_realtime_cache['timestamp'] = current_time
|
||||
logger.info(f"[缓存更新] A股实时行情(东财) 缓存已刷新,TTL={_realtime_cache['ttl']}s")
|
||||
|
||||
if df is None or df.empty:
|
||||
logger.warning(f"[实时行情] A股实时行情数据为空,跳过 {stock_code}")
|
||||
@@ -667,24 +577,22 @@ class AkshareFetcher(BaseFetcher):
|
||||
|
||||
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(
|
||||
# 使用 realtime_types.py 中的统一转换函数
|
||||
quote = UnifiedRealtimeQuote(
|
||||
code=stock_code,
|
||||
name=str(row.get('名称', '')),
|
||||
source=RealtimeSource.AKSHARE_EM,
|
||||
price=safe_float(row.get('最新价')),
|
||||
change_pct=safe_float(row.get('涨跌幅')),
|
||||
change_amount=safe_float(row.get('涨跌额')),
|
||||
volume=safe_int(row.get('成交量')),
|
||||
amount=safe_float(row.get('成交额')),
|
||||
volume_ratio=safe_float(row.get('量比')),
|
||||
turnover_rate=safe_float(row.get('换手率')),
|
||||
amplitude=safe_float(row.get('振幅')),
|
||||
open_price=safe_float(row.get('今开')),
|
||||
high=safe_float(row.get('最高')),
|
||||
low=safe_float(row.get('最低')),
|
||||
pe_ratio=safe_float(row.get('市盈率-动态')),
|
||||
pb_ratio=safe_float(row.get('市净率')),
|
||||
total_mv=safe_float(row.get('总市值')),
|
||||
@@ -694,16 +602,205 @@ class AkshareFetcher(BaseFetcher):
|
||||
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}")
|
||||
logger.info(f"[实时行情-东财] {stock_code} {quote.name}: 价格={quote.price}, 涨跌={quote.change_pct}%, "
|
||||
f"量比={quote.volume_ratio}, 换手率={quote.turnover_rate}%")
|
||||
return quote
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[API错误] 获取 {stock_code} 实时行情失败: {e}")
|
||||
logger.error(f"[API错误] 获取 {stock_code} 实时行情(东财)失败: {e}")
|
||||
circuit_breaker.record_failure(source_key, str(e))
|
||||
return None
|
||||
|
||||
def _get_etf_realtime_quote(self, stock_code: str) -> Optional[RealtimeQuote]:
|
||||
def _get_stock_realtime_quote_sina(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]:
|
||||
"""
|
||||
获取普通 A 股实时行情数据(新浪财经数据源)
|
||||
|
||||
数据来源:新浪财经接口(直连,单股票查询)
|
||||
优点:单股票查询,负载小,速度快
|
||||
缺点:数据字段较少,无量比/PE/PB等
|
||||
|
||||
接口格式:http://hq.sinajs.cn/list=sh600519,sz000001
|
||||
"""
|
||||
circuit_breaker = get_realtime_circuit_breaker()
|
||||
source_key = "akshare_sina"
|
||||
|
||||
try:
|
||||
import requests
|
||||
|
||||
# 判断市场前缀
|
||||
if stock_code.startswith(('6', '5', '9')):
|
||||
symbol = f"sh{stock_code}"
|
||||
else:
|
||||
symbol = f"sz{stock_code}"
|
||||
|
||||
url = f"http://hq.sinajs.cn/list={symbol}"
|
||||
headers = {
|
||||
'Referer': 'http://finance.sina.com.cn',
|
||||
'User-Agent': random.choice(USER_AGENTS)
|
||||
}
|
||||
|
||||
logger.info(f"[API调用] 新浪财经接口获取 {stock_code} 实时行情...")
|
||||
|
||||
self._enforce_rate_limit()
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.encoding = 'gbk'
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"[API错误] 新浪接口返回状态码 {response.status_code}")
|
||||
circuit_breaker.record_failure(source_key, f"HTTP {response.status_code}")
|
||||
return None
|
||||
|
||||
# 解析数据:var hq_str_sh600519="贵州茅台,1866.000,1870.000,..."
|
||||
content = response.text.strip()
|
||||
if '=""' in content or not content:
|
||||
logger.warning(f"[API返回] 新浪接口未找到 {stock_code} 数据")
|
||||
return None
|
||||
|
||||
# 提取引号内的数据
|
||||
data_start = content.find('"')
|
||||
data_end = content.rfind('"')
|
||||
if data_start == -1 or data_end == -1:
|
||||
logger.warning(f"[API返回] 新浪接口数据格式异常")
|
||||
circuit_breaker.record_failure(source_key, "数据格式异常")
|
||||
return None
|
||||
|
||||
data_str = content[data_start+1:data_end]
|
||||
fields = data_str.split(',')
|
||||
|
||||
if len(fields) < 32:
|
||||
logger.warning(f"[API返回] 新浪接口数据字段不足: {len(fields)}")
|
||||
return None
|
||||
|
||||
circuit_breaker.record_success(source_key)
|
||||
|
||||
# 新浪数据字段顺序:
|
||||
# 0:名称 1:今开 2:昨收 3:最新价 4:最高 5:最低 6:买一价 7:卖一价
|
||||
# 8:成交量(股) 9:成交额(元) ... 30:日期 31:时间
|
||||
# 使用 realtime_types.py 中的统一转换函数
|
||||
price = safe_float(fields[3])
|
||||
pre_close = safe_float(fields[2])
|
||||
change_pct = None
|
||||
change_amount = None
|
||||
if price and pre_close and pre_close > 0:
|
||||
change_amount = price - pre_close
|
||||
change_pct = (change_amount / pre_close) * 100
|
||||
|
||||
quote = UnifiedRealtimeQuote(
|
||||
code=stock_code,
|
||||
name=fields[0],
|
||||
source=RealtimeSource.AKSHARE_SINA,
|
||||
price=price,
|
||||
change_pct=change_pct,
|
||||
change_amount=change_amount,
|
||||
volume=safe_int(fields[8]), # 成交量(股)
|
||||
amount=safe_float(fields[9]), # 成交额(元)
|
||||
open_price=safe_float(fields[1]),
|
||||
high=safe_float(fields[4]),
|
||||
low=safe_float(fields[5]),
|
||||
pre_close=pre_close,
|
||||
)
|
||||
|
||||
logger.info(f"[实时行情-新浪] {stock_code} {quote.name}: 价格={quote.price}, "
|
||||
f"涨跌={quote.change_pct:.2f}%" if quote.change_pct else "")
|
||||
return quote
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[API错误] 获取 {stock_code} 实时行情(新浪)失败: {e}")
|
||||
circuit_breaker.record_failure(source_key, str(e))
|
||||
return None
|
||||
|
||||
def _get_stock_realtime_quote_tencent(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]:
|
||||
"""
|
||||
获取普通 A 股实时行情数据(腾讯财经数据源)
|
||||
|
||||
数据来源:腾讯财经接口(直连,单股票查询)
|
||||
优点:单股票查询,负载小,包含换手率
|
||||
缺点:无量比/PE/PB等估值数据
|
||||
|
||||
接口格式:http://qt.gtimg.cn/q=sh600519,sz000001
|
||||
"""
|
||||
circuit_breaker = get_realtime_circuit_breaker()
|
||||
source_key = "tencent"
|
||||
|
||||
try:
|
||||
import requests
|
||||
|
||||
# 判断市场前缀
|
||||
if stock_code.startswith(('6', '5', '9')):
|
||||
symbol = f"sh{stock_code}"
|
||||
else:
|
||||
symbol = f"sz{stock_code}"
|
||||
|
||||
url = f"http://qt.gtimg.cn/q={symbol}"
|
||||
headers = {
|
||||
'Referer': 'http://finance.qq.com',
|
||||
'User-Agent': random.choice(USER_AGENTS)
|
||||
}
|
||||
|
||||
logger.info(f"[API调用] 腾讯财经接口获取 {stock_code} 实时行情...")
|
||||
|
||||
self._enforce_rate_limit()
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.encoding = 'gbk'
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"[API错误] 腾讯接口返回状态码 {response.status_code}")
|
||||
circuit_breaker.record_failure(source_key, f"HTTP {response.status_code}")
|
||||
return None
|
||||
|
||||
content = response.text.strip()
|
||||
if '=""' in content or not content:
|
||||
logger.warning(f"[API返回] 腾讯接口未找到 {stock_code} 数据")
|
||||
return None
|
||||
|
||||
# 提取数据
|
||||
data_start = content.find('"')
|
||||
data_end = content.rfind('"')
|
||||
if data_start == -1 or data_end == -1:
|
||||
logger.warning(f"[API返回] 腾讯接口数据格式异常")
|
||||
circuit_breaker.record_failure(source_key, "数据格式异常")
|
||||
return None
|
||||
|
||||
data_str = content[data_start+1:data_end]
|
||||
fields = data_str.split('~')
|
||||
|
||||
if len(fields) < 45:
|
||||
logger.warning(f"[API返回] 腾讯接口数据字段不足: {len(fields)}")
|
||||
return None
|
||||
|
||||
circuit_breaker.record_success(source_key)
|
||||
|
||||
# 腾讯数据字段顺序(部分):
|
||||
# 1:名称 2:代码 3:最新价 4:昨收 5:今开 6:成交量(手) 7:外盘 8:内盘
|
||||
# 9:买一价 10:买一量 ... 30:最高 31:最低 32:涨跌幅(%) 33:涨跌额
|
||||
# 38:换手率(%) 39:市盈率 44:振幅
|
||||
# 使用 realtime_types.py 中的统一转换函数
|
||||
quote = UnifiedRealtimeQuote(
|
||||
code=stock_code,
|
||||
name=fields[1] if len(fields) > 1 else "",
|
||||
source=RealtimeSource.TENCENT,
|
||||
price=safe_float(fields[3]),
|
||||
change_pct=safe_float(fields[32]),
|
||||
change_amount=safe_float(fields[31]) if len(fields) > 31 else None,
|
||||
volume=safe_int(fields[6]) * 100 if fields[6] else None, # 腾讯返回的是手,转为股
|
||||
open_price=safe_float(fields[5]),
|
||||
high=safe_float(fields[33]) if len(fields) > 33 else None,
|
||||
low=safe_float(fields[34]) if len(fields) > 34 else None,
|
||||
pre_close=safe_float(fields[4]),
|
||||
turnover_rate=safe_float(fields[38]) if len(fields) > 38 else None,
|
||||
amplitude=safe_float(fields[43]) if len(fields) > 43 else None,
|
||||
)
|
||||
|
||||
logger.info(f"[实时行情-腾讯] {stock_code} {quote.name}: 价格={quote.price}, "
|
||||
f"涨跌={quote.change_pct}%, 换手率={quote.turnover_rate}%")
|
||||
return quote
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[API错误] 获取 {stock_code} 实时行情(腾讯)失败: {e}")
|
||||
circuit_breaker.record_failure(source_key, str(e))
|
||||
return None
|
||||
|
||||
def _get_etf_realtime_quote(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]:
|
||||
"""
|
||||
获取 ETF 基金实时行情数据
|
||||
|
||||
@@ -714,9 +811,11 @@ class AkshareFetcher(BaseFetcher):
|
||||
stock_code: ETF 代码
|
||||
|
||||
Returns:
|
||||
RealtimeQuote 对象,获取失败返回 None
|
||||
UnifiedRealtimeQuote 对象,获取失败返回 None
|
||||
"""
|
||||
import akshare as ak
|
||||
circuit_breaker = get_realtime_circuit_breaker()
|
||||
source_key = "akshare_etf"
|
||||
|
||||
try:
|
||||
# 检查缓存
|
||||
@@ -742,6 +841,7 @@ class AkshareFetcher(BaseFetcher):
|
||||
|
||||
api_elapsed = _time.time() - api_start
|
||||
logger.info(f"[API返回] ak.fund_etf_spot_em 成功: 返回 {len(df)} 只ETF, 耗时 {api_elapsed:.2f}s")
|
||||
circuit_breaker.record_success(source_key)
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
@@ -750,6 +850,7 @@ class AkshareFetcher(BaseFetcher):
|
||||
|
||||
if df is None:
|
||||
logger.error(f"[API错误] ak.fund_etf_spot_em 最终失败: {last_error}")
|
||||
circuit_breaker.record_failure(source_key, str(last_error))
|
||||
df = pd.DataFrame()
|
||||
_etf_realtime_cache['data'] = df
|
||||
_etf_realtime_cache['timestamp'] = current_time
|
||||
@@ -766,32 +867,27 @@ class AkshareFetcher(BaseFetcher):
|
||||
|
||||
row = row.iloc[0]
|
||||
|
||||
# 安全获取字段值
|
||||
def safe_float(val, default=0.0):
|
||||
try:
|
||||
if pd.isna(val):
|
||||
return default
|
||||
return float(val)
|
||||
except:
|
||||
return default
|
||||
|
||||
# ETF 行情数据构建(部分字段 ETF 可能不支持,使用默认值)
|
||||
quote = RealtimeQuote(
|
||||
# 使用 realtime_types.py 中的统一转换函数
|
||||
# ETF 行情数据构建
|
||||
quote = UnifiedRealtimeQuote(
|
||||
code=stock_code,
|
||||
name=str(row.get('名称', '')),
|
||||
source=RealtimeSource.AKSHARE_EM,
|
||||
price=safe_float(row.get('最新价')),
|
||||
change_pct=safe_float(row.get('涨跌幅')),
|
||||
change_amount=safe_float(row.get('涨跌额')),
|
||||
volume_ratio=safe_float(row.get('量比', 0)), # ETF 可能无量比
|
||||
volume=safe_int(row.get('成交量')),
|
||||
amount=safe_float(row.get('成交额')),
|
||||
volume_ratio=safe_float(row.get('量比')),
|
||||
turnover_rate=safe_float(row.get('换手率')),
|
||||
amplitude=safe_float(row.get('振幅')),
|
||||
pe_ratio=0.0, # ETF 通常无市盈率
|
||||
pb_ratio=0.0, # ETF 通常无市净率
|
||||
total_mv=safe_float(row.get('总市值', 0)),
|
||||
circ_mv=safe_float(row.get('流通市值', 0)),
|
||||
change_60d=0.0, # ETF 接口可能不提供
|
||||
high_52w=safe_float(row.get('52周最高', 0)),
|
||||
low_52w=safe_float(row.get('52周最低', 0)),
|
||||
open_price=safe_float(row.get('今开')),
|
||||
high=safe_float(row.get('最高')),
|
||||
low=safe_float(row.get('最低')),
|
||||
total_mv=safe_float(row.get('总市值')),
|
||||
circ_mv=safe_float(row.get('流通市值')),
|
||||
high_52w=safe_float(row.get('52周最高')),
|
||||
low_52w=safe_float(row.get('52周最低')),
|
||||
)
|
||||
|
||||
logger.info(f"[ETF实时行情] {stock_code} {quote.name}: 价格={quote.price}, 涨跌={quote.change_pct}%, "
|
||||
@@ -800,9 +896,10 @@ class AkshareFetcher(BaseFetcher):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[API错误] 获取 ETF {stock_code} 实时行情失败: {e}")
|
||||
circuit_breaker.record_failure(source_key, str(e))
|
||||
return None
|
||||
|
||||
def _get_hk_realtime_quote(self, stock_code: str) -> Optional[RealtimeQuote]:
|
||||
def _get_hk_realtime_quote(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]:
|
||||
"""
|
||||
获取港股实时行情数据
|
||||
|
||||
@@ -813,9 +910,11 @@ class AkshareFetcher(BaseFetcher):
|
||||
stock_code: 港股代码
|
||||
|
||||
Returns:
|
||||
RealtimeQuote 对象,获取失败返回 None
|
||||
UnifiedRealtimeQuote 对象,获取失败返回 None
|
||||
"""
|
||||
import akshare as ak
|
||||
circuit_breaker = get_realtime_circuit_breaker()
|
||||
source_key = "akshare_hk"
|
||||
|
||||
try:
|
||||
# 防封禁策略
|
||||
@@ -833,6 +932,7 @@ class AkshareFetcher(BaseFetcher):
|
||||
|
||||
api_elapsed = _time.time() - api_start
|
||||
logger.info(f"[API返回] ak.stock_hk_spot_em 成功: 返回 {len(df)} 只港股, 耗时 {api_elapsed:.2f}s")
|
||||
circuit_breaker.record_success(source_key)
|
||||
|
||||
# 查找指定港股
|
||||
row = df[df['代码'] == code]
|
||||
@@ -842,32 +942,26 @@ class AkshareFetcher(BaseFetcher):
|
||||
|
||||
row = row.iloc[0]
|
||||
|
||||
# 安全获取字段值
|
||||
def safe_float(val, default=0.0):
|
||||
try:
|
||||
if pd.isna(val):
|
||||
return default
|
||||
return float(val)
|
||||
except:
|
||||
return default
|
||||
|
||||
# 使用 realtime_types.py 中的统一转换函数
|
||||
# 港股行情数据构建
|
||||
quote = RealtimeQuote(
|
||||
quote = UnifiedRealtimeQuote(
|
||||
code=stock_code,
|
||||
name=str(row.get('名称', '')),
|
||||
source=RealtimeSource.AKSHARE_EM,
|
||||
price=safe_float(row.get('最新价')),
|
||||
change_pct=safe_float(row.get('涨跌幅')),
|
||||
change_amount=safe_float(row.get('涨跌额')),
|
||||
volume_ratio=safe_float(row.get('量比', 0)), # 港股可能无量比
|
||||
turnover_rate=safe_float(row.get('换手率', 0)),
|
||||
amplitude=safe_float(row.get('振幅', 0)),
|
||||
pe_ratio=safe_float(row.get('市盈率', 0)), # 港股可能有市盈率
|
||||
pb_ratio=safe_float(row.get('市净率', 0)), # 港股可能有市净率
|
||||
total_mv=safe_float(row.get('总市值', 0)),
|
||||
circ_mv=safe_float(row.get('流通市值', 0)),
|
||||
change_60d=0.0, # 港股接口可能不提供
|
||||
high_52w=safe_float(row.get('52周最高', 0)),
|
||||
low_52w=safe_float(row.get('52周最低', 0)),
|
||||
volume=safe_int(row.get('成交量')),
|
||||
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('流通市值')),
|
||||
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}%, "
|
||||
@@ -876,6 +970,7 @@ class AkshareFetcher(BaseFetcher):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[API错误] 获取港股 {stock_code} 实时行情失败: {e}")
|
||||
circuit_breaker.record_failure(source_key, str(e))
|
||||
return None
|
||||
|
||||
def get_chip_distribution(self, stock_code: str) -> Optional[ChipDistribution]:
|
||||
@@ -928,14 +1023,7 @@ class AkshareFetcher(BaseFetcher):
|
||||
# 取最新一天的数据
|
||||
latest = df.iloc[-1]
|
||||
|
||||
def safe_float(val, default=0.0):
|
||||
try:
|
||||
if pd.isna(val):
|
||||
return default
|
||||
return float(val)
|
||||
except:
|
||||
return default
|
||||
|
||||
# 使用 realtime_types.py 中的统一转换函数
|
||||
chip = ChipDistribution(
|
||||
code=stock_code,
|
||||
date=str(latest.get('日期', '')),
|
||||
|
||||
@@ -366,3 +366,214 @@ class DataFetcherManager:
|
||||
def available_fetchers(self) -> List[str]:
|
||||
"""返回可用数据源名称列表"""
|
||||
return [f.name for f in self._fetchers]
|
||||
|
||||
def prefetch_realtime_quotes(self, stock_codes: List[str]) -> int:
|
||||
"""
|
||||
批量预取实时行情数据(在分析开始前调用)
|
||||
|
||||
策略:
|
||||
1. 检查优先级中是否包含全量拉取数据源(efinance/akshare_em)
|
||||
2. 如果不包含,跳过预取(新浪/腾讯是单股票查询,无需预取)
|
||||
3. 如果自选股数量 >= 5 且使用全量数据源,则预取填充缓存
|
||||
|
||||
这样做的好处:
|
||||
- 使用新浪/腾讯时:每只股票独立查询,无全量拉取问题
|
||||
- 使用 efinance/东财时:预取一次,后续缓存命中
|
||||
|
||||
Args:
|
||||
stock_codes: 待分析的股票代码列表
|
||||
|
||||
Returns:
|
||||
预取的股票数量(0 表示跳过预取)
|
||||
"""
|
||||
from config import get_config
|
||||
|
||||
config = get_config()
|
||||
|
||||
# 如果实时行情被禁用,跳过预取
|
||||
if not config.enable_realtime_quote:
|
||||
logger.debug("[预取] 实时行情功能已禁用,跳过预取")
|
||||
return 0
|
||||
|
||||
# 检查优先级中是否包含全量拉取数据源
|
||||
# 注意:新增全量接口(如 tushare_realtime)时需同步更新此列表
|
||||
# 全量接口特征:一次 API 调用拉取全市场 5000+ 股票数据
|
||||
priority = config.realtime_source_priority.lower()
|
||||
bulk_sources = ['efinance', 'akshare_em'] # TODO: 新增全量接口需同步更新此处
|
||||
|
||||
# 如果优先级中前两个都不是全量数据源,跳过预取
|
||||
# 因为新浪/腾讯是单股票查询,不需要预取
|
||||
priority_list = [s.strip() for s in priority.split(',')]
|
||||
first_bulk_source_index = None
|
||||
for i, source in enumerate(priority_list):
|
||||
if source in bulk_sources:
|
||||
first_bulk_source_index = i
|
||||
break
|
||||
|
||||
# 如果没有全量数据源,或者全量数据源排在第 3 位之后,跳过预取
|
||||
if first_bulk_source_index is None or first_bulk_source_index >= 2:
|
||||
logger.info(f"[预取] 当前优先级使用轻量级数据源(sina/tencent),无需预取")
|
||||
return 0
|
||||
|
||||
# 如果股票数量少于 5 个,不进行批量预取(逐个查询更高效)
|
||||
if len(stock_codes) < 5:
|
||||
logger.info(f"[预取] 股票数量 {len(stock_codes)} < 5,跳过批量预取")
|
||||
return 0
|
||||
|
||||
logger.info(f"[预取] 开始批量预取实时行情,共 {len(stock_codes)} 只股票...")
|
||||
|
||||
# 尝试通过 efinance 或 akshare 预取
|
||||
# 只需要调用一次 get_realtime_quote,缓存机制会自动拉取全市场数据
|
||||
try:
|
||||
# 用第一只股票触发全量拉取
|
||||
first_code = stock_codes[0]
|
||||
quote = self.get_realtime_quote(first_code)
|
||||
|
||||
if quote:
|
||||
logger.info(f"[预取] 批量预取完成,缓存已填充")
|
||||
return len(stock_codes)
|
||||
else:
|
||||
logger.warning(f"[预取] 批量预取失败,将使用逐个查询模式")
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[预取] 批量预取异常: {e}")
|
||||
return 0
|
||||
|
||||
def get_realtime_quote(self, stock_code: str):
|
||||
"""
|
||||
获取实时行情数据(自动故障切换)
|
||||
|
||||
故障切换策略(按配置的优先级):
|
||||
1. EfinanceFetcher.get_realtime_quote()
|
||||
2. AkshareFetcher.get_realtime_quote(source="em") - 东财
|
||||
3. AkshareFetcher.get_realtime_quote(source="sina") - 新浪
|
||||
4. AkshareFetcher.get_realtime_quote(source="tencent") - 腾讯
|
||||
5. 返回 None(降级兜底)
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码
|
||||
|
||||
Returns:
|
||||
UnifiedRealtimeQuote 对象,所有数据源都失败则返回 None
|
||||
"""
|
||||
from .realtime_types import get_realtime_circuit_breaker
|
||||
from config import get_config
|
||||
|
||||
config = get_config()
|
||||
|
||||
# 如果实时行情功能被禁用,直接返回 None
|
||||
if not config.enable_realtime_quote:
|
||||
logger.debug(f"[实时行情] 功能已禁用,跳过 {stock_code}")
|
||||
return None
|
||||
|
||||
# 获取配置的数据源优先级
|
||||
source_priority = config.realtime_source_priority.split(',')
|
||||
|
||||
errors = []
|
||||
|
||||
for source in source_priority:
|
||||
source = source.strip().lower()
|
||||
|
||||
try:
|
||||
quote = None
|
||||
|
||||
if source == "efinance":
|
||||
# 尝试 EfinanceFetcher
|
||||
for fetcher in self._fetchers:
|
||||
if fetcher.name == "EfinanceFetcher":
|
||||
if hasattr(fetcher, 'get_realtime_quote'):
|
||||
quote = fetcher.get_realtime_quote(stock_code)
|
||||
break
|
||||
|
||||
elif source == "akshare_em":
|
||||
# 尝试 AkshareFetcher 东财数据源
|
||||
for fetcher in self._fetchers:
|
||||
if fetcher.name == "AkshareFetcher":
|
||||
if hasattr(fetcher, 'get_realtime_quote'):
|
||||
quote = fetcher.get_realtime_quote(stock_code, source="em")
|
||||
break
|
||||
|
||||
elif source == "akshare_sina":
|
||||
# 尝试 AkshareFetcher 新浪数据源
|
||||
for fetcher in self._fetchers:
|
||||
if fetcher.name == "AkshareFetcher":
|
||||
if hasattr(fetcher, 'get_realtime_quote'):
|
||||
quote = fetcher.get_realtime_quote(stock_code, source="sina")
|
||||
break
|
||||
|
||||
elif source in ("tencent", "akshare_qq"):
|
||||
# 尝试 AkshareFetcher 腾讯数据源
|
||||
for fetcher in self._fetchers:
|
||||
if fetcher.name == "AkshareFetcher":
|
||||
if hasattr(fetcher, 'get_realtime_quote'):
|
||||
quote = fetcher.get_realtime_quote(stock_code, source="tencent")
|
||||
break
|
||||
|
||||
if quote is not None and quote.has_basic_data():
|
||||
logger.info(f"[实时行情] {stock_code} 成功获取 (来源: {source})")
|
||||
return quote
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"[{source}] 失败: {str(e)}"
|
||||
logger.warning(error_msg)
|
||||
errors.append(error_msg)
|
||||
continue
|
||||
|
||||
# 所有数据源都失败,返回 None(降级兜底)
|
||||
if errors:
|
||||
logger.warning(f"[实时行情] {stock_code} 所有数据源均失败,降级处理: {'; '.join(errors)}")
|
||||
else:
|
||||
logger.warning(f"[实时行情] {stock_code} 无可用数据源")
|
||||
|
||||
return None
|
||||
|
||||
def get_chip_distribution(self, stock_code: str):
|
||||
"""
|
||||
获取筹码分布数据(带熔断和降级)
|
||||
|
||||
策略:
|
||||
1. 检查配置开关
|
||||
2. 检查熔断器状态
|
||||
3. 调用 AkshareFetcher.get_chip_distribution()
|
||||
4. 失败则返回 None(降级兜底)
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码
|
||||
|
||||
Returns:
|
||||
ChipDistribution 对象,失败则返回 None
|
||||
"""
|
||||
from .realtime_types import get_chip_circuit_breaker
|
||||
from config import get_config
|
||||
|
||||
config = get_config()
|
||||
|
||||
# 如果筹码分布功能被禁用,直接返回 None
|
||||
if not config.enable_chip_distribution:
|
||||
logger.debug(f"[筹码分布] 功能已禁用,跳过 {stock_code}")
|
||||
return None
|
||||
|
||||
# 检查熔断器状态
|
||||
circuit_breaker = get_chip_circuit_breaker()
|
||||
if not circuit_breaker.is_available("akshare_chip"):
|
||||
logger.warning(f"[熔断] 筹码接口处于熔断状态,跳过 {stock_code}")
|
||||
return None
|
||||
|
||||
try:
|
||||
# 调用 AkshareFetcher 获取筹码分布
|
||||
for fetcher in self._fetchers:
|
||||
if fetcher.name == "AkshareFetcher":
|
||||
if hasattr(fetcher, 'get_chip_distribution'):
|
||||
chip = fetcher.get_chip_distribution(stock_code)
|
||||
if chip is not None:
|
||||
circuit_breaker.record_success("akshare_chip")
|
||||
return chip
|
||||
break
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[筹码分布] 获取 {stock_code} 失败: {e}")
|
||||
circuit_breaker.record_failure("akshare_chip", str(e))
|
||||
return None
|
||||
@@ -17,6 +17,7 @@ EfinanceFetcher - 优先数据源 (Priority 0)
|
||||
1. 每次请求前随机休眠 1.5-3.0 秒
|
||||
2. 随机轮换 User-Agent
|
||||
3. 使用 tenacity 实现指数退避重试
|
||||
4. 熔断器机制:连续失败后自动冷却
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -36,14 +37,20 @@ from tenacity import (
|
||||
)
|
||||
|
||||
from .base import BaseFetcher, DataFetchError, RateLimitError, STANDARD_COLUMNS
|
||||
from .realtime_types import (
|
||||
UnifiedRealtimeQuote, RealtimeSource,
|
||||
get_realtime_circuit_breaker,
|
||||
safe_float, safe_int # 使用统一的类型转换函数
|
||||
)
|
||||
|
||||
|
||||
# 保留旧的类型别名,用于向后兼容
|
||||
@dataclass
|
||||
class EfinanceRealtimeQuote:
|
||||
"""
|
||||
实时行情数据(来自 efinance)
|
||||
实时行情数据(来自 efinance)- 向后兼容别名
|
||||
|
||||
包含当日实时交易数据和估值指标
|
||||
新代码建议使用 UnifiedRealtimeQuote
|
||||
"""
|
||||
code: str
|
||||
name: str = ""
|
||||
@@ -94,10 +101,11 @@ USER_AGENTS = [
|
||||
|
||||
|
||||
# 缓存实时行情数据(避免重复请求)
|
||||
# TTL 设为 10 分钟 (600秒):批量分析场景下避免重复拉取
|
||||
_realtime_cache: Dict[str, Any] = {
|
||||
'data': None,
|
||||
'timestamp': 0,
|
||||
'ttl': 60 # 60秒缓存有效期
|
||||
'ttl': 600 # 10分钟缓存有效期
|
||||
}
|
||||
|
||||
|
||||
@@ -400,9 +408,16 @@ class EfinanceFetcher(BaseFetcher):
|
||||
stock_code: 股票代码
|
||||
|
||||
Returns:
|
||||
EfinanceRealtimeQuote 对象,获取失败返回 None
|
||||
UnifiedRealtimeQuote 对象,获取失败返回 None
|
||||
"""
|
||||
import efinance as ef
|
||||
circuit_breaker = get_realtime_circuit_breaker()
|
||||
source_key = "efinance"
|
||||
|
||||
# 检查熔断器状态
|
||||
if not circuit_breaker.is_available(source_key):
|
||||
logger.warning(f"[熔断] 数据源 {source_key} 处于熔断状态,跳过")
|
||||
return None
|
||||
|
||||
try:
|
||||
# 检查缓存
|
||||
@@ -410,8 +425,11 @@ class EfinanceFetcher(BaseFetcher):
|
||||
if (_realtime_cache['data'] is not None and
|
||||
current_time - _realtime_cache['timestamp'] < _realtime_cache['ttl']):
|
||||
df = _realtime_cache['data']
|
||||
logger.debug(f"[缓存命中] 使用缓存的实时行情数据")
|
||||
cache_age = int(current_time - _realtime_cache['timestamp'])
|
||||
logger.debug(f"[缓存命中] 实时行情(efinance) - 缓存年龄 {cache_age}s/{_realtime_cache['ttl']}s")
|
||||
else:
|
||||
# 触发全量刷新
|
||||
logger.info(f"[缓存未命中] 触发全量刷新 实时行情(efinance)")
|
||||
# 防封禁策略
|
||||
self._set_random_user_agent()
|
||||
self._enforce_rate_limit()
|
||||
@@ -425,10 +443,12 @@ class EfinanceFetcher(BaseFetcher):
|
||||
|
||||
api_elapsed = _time.time() - api_start
|
||||
logger.info(f"[API返回] ef.stock.get_realtime_quotes 成功: 返回 {len(df)} 只股票, 耗时 {api_elapsed:.2f}s")
|
||||
circuit_breaker.record_success(source_key)
|
||||
|
||||
# 更新缓存
|
||||
_realtime_cache['data'] = df
|
||||
_realtime_cache['timestamp'] = current_time
|
||||
logger.info(f"[缓存更新] 实时行情(efinance) 缓存已刷新,TTL={_realtime_cache['ttl']}s")
|
||||
|
||||
# 查找指定股票
|
||||
# efinance 返回的列名可能是 '股票代码' 或 'code'
|
||||
@@ -440,23 +460,7 @@ class EfinanceFetcher(BaseFetcher):
|
||||
|
||||
row = row.iloc[0]
|
||||
|
||||
# 安全获取字段值
|
||||
def safe_float(val, default=0.0):
|
||||
try:
|
||||
if pd.isna(val):
|
||||
return default
|
||||
return float(val)
|
||||
except:
|
||||
return default
|
||||
|
||||
def safe_int(val, default=0):
|
||||
try:
|
||||
if pd.isna(val):
|
||||
return default
|
||||
return int(float(val))
|
||||
except:
|
||||
return default
|
||||
|
||||
# 使用 realtime_types.py 中的统一转换函数
|
||||
# 获取列名(可能是中文或英文)
|
||||
name_col = '股票名称' if '股票名称' in df.columns else 'name'
|
||||
price_col = '最新价' if '最新价' in df.columns else 'price'
|
||||
@@ -470,9 +474,10 @@ class EfinanceFetcher(BaseFetcher):
|
||||
low_col = '最低' if '最低' in df.columns else 'low'
|
||||
open_col = '开盘' if '开盘' in df.columns else 'open'
|
||||
|
||||
quote = EfinanceRealtimeQuote(
|
||||
quote = UnifiedRealtimeQuote(
|
||||
code=stock_code,
|
||||
name=str(row.get(name_col, '')),
|
||||
source=RealtimeSource.EFINANCE,
|
||||
price=safe_float(row.get(price_col)),
|
||||
change_pct=safe_float(row.get(pct_col)),
|
||||
change_amount=safe_float(row.get(chg_col)),
|
||||
@@ -485,12 +490,13 @@ class EfinanceFetcher(BaseFetcher):
|
||||
open_price=safe_float(row.get(open_col)),
|
||||
)
|
||||
|
||||
logger.info(f"[实时行情] {stock_code} {quote.name}: 价格={quote.price}, 涨跌={quote.change_pct}%, "
|
||||
logger.info(f"[实时行情-efinance] {stock_code} {quote.name}: 价格={quote.price}, 涨跌={quote.change_pct}%, "
|
||||
f"换手率={quote.turnover_rate}%")
|
||||
return quote
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[API错误] 获取 {stock_code} 实时行情失败: {e}")
|
||||
logger.error(f"[API错误] 获取 {stock_code} 实时行情(efinance)失败: {e}")
|
||||
circuit_breaker.record_failure(source_key, str(e))
|
||||
return None
|
||||
|
||||
def get_base_info(self, stock_code: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
413
data_provider/realtime_types.py
Normal file
413
data_provider/realtime_types.py
Normal file
@@ -0,0 +1,413 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
实时行情统一类型定义 & 熔断机制
|
||||
===================================
|
||||
|
||||
设计目标:
|
||||
1. 统一各数据源的实时行情返回结构
|
||||
2. 实现熔断/冷却机制,避免连续失败时反复请求
|
||||
3. 支持多数据源故障切换
|
||||
|
||||
使用方式:
|
||||
- 所有 Fetcher 的 get_realtime_quote() 统一返回 UnifiedRealtimeQuote
|
||||
- CircuitBreaker 管理各数据源的熔断状态
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 通用类型转换工具函数
|
||||
# ============================================
|
||||
# 设计说明:
|
||||
# 各数据源返回的原始数据类型不一致(str/float/int/NaN),
|
||||
# 使用这些函数统一转换,避免在各 Fetcher 中重复定义。
|
||||
|
||||
def safe_float(val: Any, default: Optional[float] = None) -> Optional[float]:
|
||||
"""
|
||||
安全转换为浮点数
|
||||
|
||||
处理场景:
|
||||
- None / 空字符串 → default
|
||||
- pandas NaN / numpy NaN → default
|
||||
- 数值字符串 → float
|
||||
- 已是数值 → float
|
||||
|
||||
Args:
|
||||
val: 待转换的值
|
||||
default: 转换失败时的默认值
|
||||
|
||||
Returns:
|
||||
转换后的浮点数,或默认值
|
||||
"""
|
||||
try:
|
||||
if val is None:
|
||||
return default
|
||||
|
||||
# 处理字符串
|
||||
if isinstance(val, str):
|
||||
val = val.strip()
|
||||
if val == "" or val == "-" or val == "--":
|
||||
return default
|
||||
|
||||
# 处理 pandas/numpy NaN
|
||||
# 使用 math.isnan 而不是 pd.isna,避免强制依赖 pandas
|
||||
import math
|
||||
try:
|
||||
if math.isnan(float(val)):
|
||||
return default
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return float(val)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def safe_int(val: Any, default: Optional[int] = None) -> Optional[int]:
|
||||
"""
|
||||
安全转换为整数
|
||||
|
||||
先转换为 float,再取整,处理 "123.0" 这类情况
|
||||
|
||||
Args:
|
||||
val: 待转换的值
|
||||
default: 转换失败时的默认值
|
||||
|
||||
Returns:
|
||||
转换后的整数,或默认值
|
||||
"""
|
||||
f_val = safe_float(val, default=None)
|
||||
if f_val is not None:
|
||||
return int(f_val)
|
||||
return default
|
||||
|
||||
|
||||
class RealtimeSource(Enum):
|
||||
"""实时行情数据源"""
|
||||
EFINANCE = "efinance" # 东方财富(efinance库)
|
||||
AKSHARE_EM = "akshare_em" # 东方财富(akshare库)
|
||||
AKSHARE_SINA = "akshare_sina" # 新浪财经
|
||||
AKSHARE_QQ = "akshare_qq" # 腾讯财经
|
||||
TENCENT = "tencent" # 腾讯直连
|
||||
SINA = "sina" # 新浪直连
|
||||
FALLBACK = "fallback" # 降级兜底
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnifiedRealtimeQuote:
|
||||
"""
|
||||
统一实时行情数据结构
|
||||
|
||||
设计原则:
|
||||
- 各数据源返回的字段可能不同,缺失字段用 None 表示
|
||||
- 主流程使用 getattr(quote, field, None) 获取,保证兼容性
|
||||
- source 字段标记数据来源,便于调试
|
||||
"""
|
||||
code: str
|
||||
name: str = ""
|
||||
source: RealtimeSource = RealtimeSource.FALLBACK
|
||||
|
||||
# === 核心价格数据(几乎所有源都有)===
|
||||
price: Optional[float] = None # 最新价
|
||||
change_pct: Optional[float] = None # 涨跌幅(%)
|
||||
change_amount: Optional[float] = None # 涨跌额
|
||||
|
||||
# === 量价指标(部分源可能缺失)===
|
||||
volume: Optional[int] = None # 成交量(手)
|
||||
amount: Optional[float] = None # 成交额(元)
|
||||
volume_ratio: Optional[float] = None # 量比
|
||||
turnover_rate: Optional[float] = None # 换手率(%)
|
||||
amplitude: Optional[float] = None # 振幅(%)
|
||||
|
||||
# === 价格区间 ===
|
||||
open_price: Optional[float] = None # 开盘价
|
||||
high: Optional[float] = None # 最高价
|
||||
low: Optional[float] = None # 最低价
|
||||
pre_close: Optional[float] = None # 昨收价
|
||||
|
||||
# === 估值指标(仅东财等全量接口有)===
|
||||
pe_ratio: Optional[float] = None # 市盈率(动态)
|
||||
pb_ratio: Optional[float] = None # 市净率
|
||||
total_mv: Optional[float] = None # 总市值(元)
|
||||
circ_mv: Optional[float] = None # 流通市值(元)
|
||||
|
||||
# === 其他指标 ===
|
||||
change_60d: Optional[float] = None # 60日涨跌幅(%)
|
||||
high_52w: Optional[float] = None # 52周最高
|
||||
low_52w: Optional[float] = None # 52周最低
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典(过滤 None 值)"""
|
||||
result = {
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'source': self.source.value,
|
||||
}
|
||||
# 只添加非 None 的字段
|
||||
optional_fields = [
|
||||
'price', 'change_pct', 'change_amount', 'volume', 'amount',
|
||||
'volume_ratio', 'turnover_rate', 'amplitude',
|
||||
'open_price', 'high', 'low', 'pre_close',
|
||||
'pe_ratio', 'pb_ratio', 'total_mv', 'circ_mv',
|
||||
'change_60d', 'high_52w', 'low_52w'
|
||||
]
|
||||
for f in optional_fields:
|
||||
val = getattr(self, f, None)
|
||||
if val is not None:
|
||||
result[f] = val
|
||||
return result
|
||||
|
||||
def has_basic_data(self) -> bool:
|
||||
"""检查是否有基本的价格数据"""
|
||||
return self.price is not None and self.price > 0
|
||||
|
||||
def has_volume_data(self) -> bool:
|
||||
"""检查是否有量价数据"""
|
||||
return self.volume_ratio is not None or self.turnover_rate is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChipDistribution:
|
||||
"""
|
||||
筹码分布数据
|
||||
|
||||
反映持仓成本分布和获利情况
|
||||
"""
|
||||
code: str
|
||||
date: str = ""
|
||||
source: str = "akshare"
|
||||
|
||||
# 获利情况
|
||||
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,
|
||||
'source': self.source,
|
||||
'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)
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""
|
||||
熔断器 - 管理数据源的熔断/冷却状态
|
||||
|
||||
策略:
|
||||
- 连续失败 N 次后进入熔断状态
|
||||
- 熔断期间跳过该数据源
|
||||
- 冷却时间后自动恢复半开状态
|
||||
- 半开状态下单次成功则完全恢复,失败则继续熔断
|
||||
|
||||
状态机:
|
||||
CLOSED(正常) --失败N次--> OPEN(熔断)--冷却时间到--> HALF_OPEN(半开)
|
||||
HALF_OPEN --成功--> CLOSED
|
||||
HALF_OPEN --失败--> OPEN
|
||||
"""
|
||||
|
||||
# 状态常量
|
||||
CLOSED = "closed" # 正常状态
|
||||
OPEN = "open" # 熔断状态(不可用)
|
||||
HALF_OPEN = "half_open" # 半开状态(试探性请求)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
failure_threshold: int = 3, # 连续失败次数阈值
|
||||
cooldown_seconds: float = 300.0, # 冷却时间(秒),默认5分钟
|
||||
half_open_max_calls: int = 1 # 半开状态最大尝试次数
|
||||
):
|
||||
self.failure_threshold = failure_threshold
|
||||
self.cooldown_seconds = cooldown_seconds
|
||||
self.half_open_max_calls = half_open_max_calls
|
||||
|
||||
# 各数据源状态 {source_name: {state, failures, last_failure_time, half_open_calls}}
|
||||
self._states: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def _get_state(self, source: str) -> Dict[str, Any]:
|
||||
"""获取或初始化数据源状态"""
|
||||
if source not in self._states:
|
||||
self._states[source] = {
|
||||
'state': self.CLOSED,
|
||||
'failures': 0,
|
||||
'last_failure_time': 0.0,
|
||||
'half_open_calls': 0
|
||||
}
|
||||
return self._states[source]
|
||||
|
||||
def is_available(self, source: str) -> bool:
|
||||
"""
|
||||
检查数据源是否可用
|
||||
|
||||
返回 True 表示可以尝试请求
|
||||
返回 False 表示应跳过该数据源
|
||||
"""
|
||||
state = self._get_state(source)
|
||||
current_time = time.time()
|
||||
|
||||
if state['state'] == self.CLOSED:
|
||||
return True
|
||||
|
||||
if state['state'] == self.OPEN:
|
||||
# 检查冷却时间
|
||||
time_since_failure = current_time - state['last_failure_time']
|
||||
if time_since_failure >= self.cooldown_seconds:
|
||||
# 冷却完成,进入半开状态
|
||||
state['state'] = self.HALF_OPEN
|
||||
state['half_open_calls'] = 0
|
||||
logger.info(f"[熔断器] {source} 冷却完成,进入半开状态")
|
||||
return True
|
||||
else:
|
||||
remaining = self.cooldown_seconds - time_since_failure
|
||||
logger.debug(f"[熔断器] {source} 处于熔断状态,剩余冷却时间: {remaining:.0f}s")
|
||||
return False
|
||||
|
||||
if state['state'] == self.HALF_OPEN:
|
||||
# 半开状态下限制请求次数
|
||||
if state['half_open_calls'] < self.half_open_max_calls:
|
||||
return True
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def record_success(self, source: str) -> None:
|
||||
"""记录成功请求"""
|
||||
state = self._get_state(source)
|
||||
|
||||
if state['state'] == self.HALF_OPEN:
|
||||
# 半开状态下成功,完全恢复
|
||||
logger.info(f"[熔断器] {source} 半开状态请求成功,恢复正常")
|
||||
|
||||
# 重置状态
|
||||
state['state'] = self.CLOSED
|
||||
state['failures'] = 0
|
||||
state['half_open_calls'] = 0
|
||||
|
||||
def record_failure(self, source: str, error: Optional[str] = None) -> None:
|
||||
"""记录失败请求"""
|
||||
state = self._get_state(source)
|
||||
current_time = time.time()
|
||||
|
||||
state['failures'] += 1
|
||||
state['last_failure_time'] = current_time
|
||||
|
||||
if state['state'] == self.HALF_OPEN:
|
||||
# 半开状态下失败,继续熔断
|
||||
state['state'] = self.OPEN
|
||||
state['half_open_calls'] = 0
|
||||
logger.warning(f"[熔断器] {source} 半开状态请求失败,继续熔断 {self.cooldown_seconds}s")
|
||||
elif state['failures'] >= self.failure_threshold:
|
||||
# 达到阈值,进入熔断
|
||||
state['state'] = self.OPEN
|
||||
logger.warning(f"[熔断器] {source} 连续失败 {state['failures']} 次,进入熔断状态 "
|
||||
f"(冷却 {self.cooldown_seconds}s)")
|
||||
if error:
|
||||
logger.warning(f"[熔断器] 最后错误: {error}")
|
||||
|
||||
def get_status(self) -> Dict[str, str]:
|
||||
"""获取所有数据源状态"""
|
||||
return {source: info['state'] for source, info in self._states.items()}
|
||||
|
||||
def reset(self, source: Optional[str] = None) -> None:
|
||||
"""重置熔断器状态"""
|
||||
if source:
|
||||
if source in self._states:
|
||||
del self._states[source]
|
||||
else:
|
||||
self._states.clear()
|
||||
|
||||
|
||||
# 全局熔断器实例(实时行情专用)
|
||||
_realtime_circuit_breaker = CircuitBreaker(
|
||||
failure_threshold=3, # 连续失败3次熔断
|
||||
cooldown_seconds=300.0, # 冷却5分钟
|
||||
half_open_max_calls=1
|
||||
)
|
||||
|
||||
# 筹码接口熔断器(更保守的策略,因为该接口更不稳定)
|
||||
_chip_circuit_breaker = CircuitBreaker(
|
||||
failure_threshold=2, # 连续失败2次熔断
|
||||
cooldown_seconds=600.0, # 冷却10分钟
|
||||
half_open_max_calls=1
|
||||
)
|
||||
|
||||
|
||||
def get_realtime_circuit_breaker() -> CircuitBreaker:
|
||||
"""获取实时行情熔断器"""
|
||||
return _realtime_circuit_breaker
|
||||
|
||||
|
||||
def get_chip_circuit_breaker() -> CircuitBreaker:
|
||||
"""获取筹码接口熔断器"""
|
||||
return _chip_circuit_breaker
|
||||
83
main.py
83
main.py
@@ -44,7 +44,7 @@ from feishu_doc import FeishuDocManager
|
||||
from config import get_config, Config
|
||||
from storage import get_db, DatabaseManager
|
||||
from data_provider import DataFetcherManager
|
||||
from data_provider.akshare_fetcher import AkshareFetcher, RealtimeQuote, ChipDistribution
|
||||
from data_provider.realtime_types import UnifiedRealtimeQuote, ChipDistribution
|
||||
from analyzer import GeminiAnalyzer, AnalysisResult, STOCK_NAME_MAP
|
||||
from notification import NotificationService, NotificationChannel
|
||||
from bot.models import BotMessage
|
||||
@@ -153,7 +153,7 @@ class StockAnalysisPipeline:
|
||||
# 初始化各模块
|
||||
self.db = get_db()
|
||||
self.fetcher_manager = DataFetcherManager()
|
||||
self.akshare_fetcher = AkshareFetcher() # 用于获取增强数据(量比、筹码等)
|
||||
# 不再单独创建 akshare_fetcher,统一使用 fetcher_manager 获取增强数据
|
||||
self.trend_analyzer = StockTrendAnalyzer() # 趋势分析器
|
||||
self.analyzer = GeminiAnalyzer()
|
||||
self.notifier = NotificationService(source_message=source_message)
|
||||
@@ -167,6 +167,15 @@ class StockAnalysisPipeline:
|
||||
|
||||
logger.info(f"调度器初始化完成,最大并发数: {self.max_workers}")
|
||||
logger.info("已启用趋势分析器 (MA5>MA10>MA20 多头判断)")
|
||||
# 打印实时行情/筹码配置状态
|
||||
if self.config.enable_realtime_quote:
|
||||
logger.info(f"实时行情已启用 (优先级: {self.config.realtime_source_priority})")
|
||||
else:
|
||||
logger.info("实时行情已禁用,将使用历史收盘价")
|
||||
if self.config.enable_chip_distribution:
|
||||
logger.info("筹码分布分析已启用")
|
||||
else:
|
||||
logger.info("筹码分布分析已禁用")
|
||||
if self.search_service.is_available:
|
||||
logger.info("搜索服务已启用 (Tavily/SerpAPI)")
|
||||
else:
|
||||
@@ -223,8 +232,8 @@ class StockAnalysisPipeline:
|
||||
分析单只股票(增强版:含量比、换手率、筹码分析、多维度情报)
|
||||
|
||||
流程:
|
||||
1. 获取实时行情(量比、换手率)
|
||||
2. 获取筹码分布
|
||||
1. 获取实时行情(量比、换手率)- 通过 DataFetcherManager 自动故障切换
|
||||
2. 获取筹码分布 - 通过 DataFetcherManager 带熔断保护
|
||||
3. 进行趋势分析(基于交易理念)
|
||||
4. 多维度情报搜索(最新消息+风险排查+业绩预期)
|
||||
5. 从数据库获取分析上下文
|
||||
@@ -240,16 +249,22 @@ class StockAnalysisPipeline:
|
||||
# 获取股票名称(优先从实时行情获取真实名称)
|
||||
stock_name = STOCK_NAME_MAP.get(code, '')
|
||||
|
||||
# Step 1: 获取实时行情(量比、换手率等)
|
||||
realtime_quote: Optional[RealtimeQuote] = None
|
||||
# Step 1: 获取实时行情(量比、换手率等)- 使用统一入口,自动故障切换
|
||||
realtime_quote = None
|
||||
try:
|
||||
realtime_quote = self.akshare_fetcher.get_realtime_quote(code)
|
||||
realtime_quote = self.fetcher_manager.get_realtime_quote(code)
|
||||
if realtime_quote:
|
||||
# 使用实时行情返回的真实股票名称
|
||||
if realtime_quote.name:
|
||||
stock_name = realtime_quote.name
|
||||
# 兼容不同数据源的字段(有些数据源可能没有 volume_ratio)
|
||||
volume_ratio = getattr(realtime_quote, 'volume_ratio', None)
|
||||
turnover_rate = getattr(realtime_quote, 'turnover_rate', None)
|
||||
logger.info(f"[{code}] {stock_name} 实时行情: 价格={realtime_quote.price}, "
|
||||
f"量比={realtime_quote.volume_ratio}, 换手率={realtime_quote.turnover_rate}%")
|
||||
f"量比={volume_ratio}, 换手率={turnover_rate}% "
|
||||
f"(来源: {realtime_quote.source.value if hasattr(realtime_quote, 'source') else 'unknown'})")
|
||||
else:
|
||||
logger.info(f"[{code}] 实时行情获取失败或已禁用,将使用历史数据进行分析")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{code}] 获取实时行情失败: {e}")
|
||||
|
||||
@@ -257,13 +272,15 @@ class StockAnalysisPipeline:
|
||||
if not stock_name:
|
||||
stock_name = f'股票{code}'
|
||||
|
||||
# Step 2: 获取筹码分布
|
||||
chip_data: Optional[ChipDistribution] = None
|
||||
# Step 2: 获取筹码分布 - 使用统一入口,带熔断保护
|
||||
chip_data = None
|
||||
try:
|
||||
chip_data = self.akshare_fetcher.get_chip_distribution(code)
|
||||
chip_data = self.fetcher_manager.get_chip_distribution(code)
|
||||
if chip_data:
|
||||
logger.info(f"[{code}] 筹码分布: 获利比例={chip_data.profit_ratio:.1%}, "
|
||||
f"90%集中度={chip_data.concentration_90:.2%}")
|
||||
else:
|
||||
logger.debug(f"[{code}] 筹码分布获取失败或已禁用")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{code}] 获取筹码分布失败: {e}")
|
||||
|
||||
@@ -335,7 +352,7 @@ class StockAnalysisPipeline:
|
||||
def _enhance_context(
|
||||
self,
|
||||
context: Dict[str, Any],
|
||||
realtime_quote: Optional[RealtimeQuote],
|
||||
realtime_quote, # UnifiedRealtimeQuote 或 None
|
||||
chip_data: Optional[ChipDistribution],
|
||||
trend_result: Optional[TrendAnalysisResult],
|
||||
stock_name: str = ""
|
||||
@@ -347,7 +364,7 @@ class StockAnalysisPipeline:
|
||||
|
||||
Args:
|
||||
context: 原始上下文
|
||||
realtime_quote: 实时行情数据
|
||||
realtime_quote: 实时行情数据(UnifiedRealtimeQuote 或 None)
|
||||
chip_data: 筹码分布数据
|
||||
trend_result: 趋势分析结果
|
||||
stock_name: 股票名称
|
||||
@@ -360,33 +377,38 @@ class StockAnalysisPipeline:
|
||||
# 添加股票名称
|
||||
if stock_name:
|
||||
enhanced['stock_name'] = stock_name
|
||||
elif realtime_quote and realtime_quote.name:
|
||||
elif realtime_quote and getattr(realtime_quote, 'name', None):
|
||||
enhanced['stock_name'] = realtime_quote.name
|
||||
|
||||
# 添加实时行情
|
||||
# 添加实时行情(兼容不同数据源的字段差异)
|
||||
if realtime_quote:
|
||||
# 使用 getattr 安全获取字段,缺失字段返回 None 或默认值
|
||||
volume_ratio = getattr(realtime_quote, 'volume_ratio', None)
|
||||
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,
|
||||
'name': getattr(realtime_quote, 'name', ''),
|
||||
'price': getattr(realtime_quote, 'price', None),
|
||||
'volume_ratio': volume_ratio,
|
||||
'volume_ratio_desc': self._describe_volume_ratio(volume_ratio) if volume_ratio else '无数据',
|
||||
'turnover_rate': getattr(realtime_quote, 'turnover_rate', None),
|
||||
'pe_ratio': getattr(realtime_quote, 'pe_ratio', None),
|
||||
'pb_ratio': getattr(realtime_quote, 'pb_ratio', None),
|
||||
'total_mv': getattr(realtime_quote, 'total_mv', None),
|
||||
'circ_mv': getattr(realtime_quote, 'circ_mv', None),
|
||||
'change_60d': getattr(realtime_quote, 'change_60d', None),
|
||||
'source': getattr(realtime_quote, 'source', None),
|
||||
}
|
||||
# 移除 None 值以减少上下文大小
|
||||
enhanced['realtime'] = {k: v for k, v in enhanced['realtime'].items() if v is not None}
|
||||
|
||||
# 添加筹码分布
|
||||
if chip_data:
|
||||
current_price = realtime_quote.price if realtime_quote else 0
|
||||
current_price = getattr(realtime_quote, 'price', 0) 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),
|
||||
'chip_status': chip_data.get_chip_status(current_price or 0),
|
||||
}
|
||||
|
||||
# 添加趋势分析结果
|
||||
@@ -541,6 +563,13 @@ class StockAnalysisPipeline:
|
||||
logger.info(f"股票列表: {', '.join(stock_codes)}")
|
||||
logger.info(f"并发数: {self.max_workers}, 模式: {'仅获取数据' if dry_run else '完整分析'}")
|
||||
|
||||
# === 批量预取实时行情(优化:避免每只股票都触发全量拉取)===
|
||||
# 只有股票数量 >= 5 时才进行预取,少量股票直接逐个查询更高效
|
||||
if len(stock_codes) >= 5:
|
||||
prefetch_count = self.fetcher_manager.prefetch_realtime_quotes(stock_codes)
|
||||
if prefetch_count > 0:
|
||||
logger.info(f"已启用批量预取架构:一次拉取全市场数据,{len(stock_codes)} 只股票共享缓存")
|
||||
|
||||
# 单股推送模式(#55):从配置读取
|
||||
single_stock_notify = getattr(self.config, 'single_stock_notify', False)
|
||||
# Issue #119: 从配置读取报告类型
|
||||
|
||||
1998
notification.py
1998
notification.py
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ google-search-results>=2.4.0 # SerpAPI(每月 100 次免费)
|
||||
|
||||
# 网络请求
|
||||
requests>=2.31.0 # HTTP 请求
|
||||
markdown2>=2.4.0 # Markdown 转 HTML
|
||||
fake-useragent>=1.4.0 # 随机 User-Agent 防封禁
|
||||
httpx[socks] # HTTP 客户端 + SOCKS 代理支持(OpenAI 可选依赖)
|
||||
dingtalk-stream >= 0.24.3 # 钉钉 Stream SDK
|
||||
|
||||
Reference in New Issue
Block a user