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:
adminlove520
2026-01-25 14:00:22 +08:00
9 changed files with 3138 additions and 298 deletions

View File

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

View File

@@ -122,6 +122,21 @@ class Config:
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
@@ -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

View File

@@ -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,138 +40,16 @@ 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:
"""
实时行情数据
# 保留旧的 RealtimeQuote 别名,用于向后兼容
RealtimeQuote = UnifiedRealtimeQuote
包含当日实时交易数据和估值指标
"""
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__)
@@ -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('日期', '')),

View File

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

View File

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

View 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
View File

@@ -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: 从配置读取报告类型

File diff suppressed because it is too large Load Diff

View File

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