You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1589 lines
62 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# -*- coding: utf-8 -*-
"""
===================================
AkshareFetcher - 主数据源 (Priority 1)
===================================
数据来源:
1. 东方财富爬虫(通过 akshare 库) - 默认数据源
2. 新浪财经接口 - 备选数据源
3. 腾讯财经接口 - 备选数据源
特点:免费、无需 Token、数据全面
风险:爬虫机制易被反爬封禁
防封禁策略:
1. 每次请求前随机休眠 2-5 秒
2. 随机轮换 User-Agent
3. 使用 tenacity 实现指数退避重试
4. 熔断器机制:连续失败后自动冷却
增强数据:
- 实时行情:量比、换手率、市盈率、市净率、总市值、流通市值
- 筹码分布:获利比例、平均成本、筹码集中度
"""
import logging
import os
import random
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, Dict, Any, List, Tuple
import pandas as pd
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
before_sleep_log,
)
from .base import BaseFetcher, DataFetchError, RateLimitError, STANDARD_COLUMNS
from .realtime_types import (
UnifiedRealtimeQuote, ChipDistribution, RealtimeSource,
get_realtime_circuit_breaker, get_chip_circuit_breaker,
safe_float, safe_int # 使用统一的类型转换函数
)
# 保留旧的 RealtimeQuote 别名,用于向后兼容
RealtimeQuote = UnifiedRealtimeQuote
logger = logging.getLogger(__name__)
# User-Agent 池,用于随机轮换
USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
]
# 缓存实时行情数据(避免重复请求)
# TTL 设为 20 分钟 (1200秒)
# - 批量分析场景:通常 30 只股票在 5 分钟内分析完20 分钟足够覆盖
# - 实时性要求股票分析不需要秒级实时数据20 分钟延迟可接受
# - 防封禁:减少 API 调用频率
_realtime_cache: Dict[str, Any] = {
'data': None,
'timestamp': 0,
'ttl': 1200 # 20分钟缓存有效期
}
# ETF 实时行情缓存
_etf_realtime_cache: Dict[str, Any] = {
'data': None,
'timestamp': 0,
'ttl': 1200 # 20分钟缓存有效期
}
def _is_etf_code(stock_code: str) -> bool:
"""
判断代码是否为 ETF 基金
ETF 代码规则:
- 上交所 ETF: 51xxxx, 52xxxx, 56xxxx, 58xxxx
- 深交所 ETF: 15xxxx, 16xxxx, 18xxxx
Args:
stock_code: 股票/基金代码
Returns:
True 表示是 ETF 代码False 表示是普通股票代码
"""
etf_prefixes = ('51', '52', '56', '58', '15', '16', '18')
code = stock_code.strip().split('.')[0]
return code.startswith(etf_prefixes) and len(code) == 6
def _is_hk_code(stock_code: str) -> bool:
"""
判断代码是否为港股
港股代码规则:
- 5位数字代码'00700' (腾讯控股)
- 部分港股代码可能带有前缀,如 'hk00700', 'hk1810'
Args:
stock_code: 股票代码
Returns:
True 表示是港股代码False 表示不是港股代码
"""
# 去除可能的 'hk' 前缀并检查是否为纯数字
code = stock_code.lower()
if code.startswith('hk'):
# 带 hk 前缀的一定是港股去掉前缀后应为纯数字1-5位
numeric_part = code[2:]
return numeric_part.isdigit() and 1 <= len(numeric_part) <= 5
# 无前缀时5位纯数字才视为港股避免误判 A 股代码)
return code.isdigit() and len(code) == 5
def _is_us_code(stock_code: str) -> bool:
"""
判断代码是否为美股
美股代码规则:
- 1-5个大写字母'AAPL' (苹果), 'TSLA' (特斯拉)
- 可能包含 '.' 用于特殊股票类别,如 'BRK.B' (伯克希尔B类股)
Args:
stock_code: 股票代码
Returns:
True 表示是美股代码False 表示不是美股代码
Examples:
>>> _is_us_code('AAPL')
True
>>> _is_us_code('TSLA')
True
>>> _is_us_code('BRK.B')
True
>>> _is_us_code('600519')
False
>>> _is_us_code('hk00700')
False
"""
import re
code = stock_code.strip().upper()
# 美股1-5个大写字母可能包含一个点和字母如 BRK.B
return bool(re.match(r'^[A-Z]{1,5}(\.[A-Z])?$', code))
class AkshareFetcher(BaseFetcher):
"""
Akshare 数据源实现
优先级1最高
数据来源:东方财富网爬虫
关键策略:
- 每次请求前随机休眠 2.0-5.0 秒
- 随机 User-Agent 轮换
- 失败后指数退避重试最多3次
"""
name = "AkshareFetcher"
priority = int(os.getenv("AKSHARE_PRIORITY", "1"))
def __init__(self, sleep_min: float = 2.0, sleep_max: float = 5.0):
"""
初始化 AkshareFetcher
Args:
sleep_min: 最小休眠时间(秒)
sleep_max: 最大休眠时间(秒)
"""
self.sleep_min = sleep_min
self.sleep_max = sleep_max
self._last_request_time: Optional[float] = None
def _set_random_user_agent(self) -> None:
"""
设置随机 User-Agent
通过修改 requests Session 的 headers 实现
这是关键的反爬策略之一
"""
try:
import akshare as ak
# akshare 内部使用 requests我们通过环境变量或直接设置来影响
# 实际上 akshare 可能不直接暴露 session这里通过 fake_useragent 作为补充
random_ua = random.choice(USER_AGENTS)
logger.debug(f"设置 User-Agent: {random_ua[:50]}...")
except Exception as e:
logger.debug(f"设置 User-Agent 失败: {e}")
def _enforce_rate_limit(self) -> None:
"""
强制执行速率限制
策略:
1. 检查距离上次请求的时间间隔
2. 如果间隔不足,补充休眠时间
3. 然后再执行随机 jitter 休眠
"""
if self._last_request_time is not None:
elapsed = time.time() - self._last_request_time
min_interval = self.sleep_min
if elapsed < min_interval:
additional_sleep = min_interval - elapsed
logger.debug(f"补充休眠 {additional_sleep:.2f}")
time.sleep(additional_sleep)
# 执行随机 jitter 休眠
self.random_sleep(self.sleep_min, self.sleep_max)
self._last_request_time = time.time()
@retry(
stop=stop_after_attempt(3), # 最多重试3次
wait=wait_exponential(multiplier=1, min=2, max=30), # 指数退避2, 4, 8... 最大30秒
retry=retry_if_exception_type((ConnectionError, TimeoutError)),
before_sleep=before_sleep_log(logger, logging.WARNING),
)
def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
"""
从 Akshare 获取原始数据
根据代码类型自动选择 API
- 美股:使用 ak.stock_us_daily()
- 港股:使用 ak.stock_hk_hist()
- ETF 基金:使用 ak.fund_etf_hist_em()
- 普通 A 股:使用 ak.stock_zh_a_hist()
流程:
1. 判断代码类型(美股/港股/ETF/A股
2. 设置随机 User-Agent
3. 执行速率限制(随机休眠)
4. 调用对应的 akshare API
5. 处理返回数据
"""
# 根据代码类型选择不同的获取方法
if _is_us_code(stock_code):
return self._fetch_us_data(stock_code, start_date, end_date)
elif _is_hk_code(stock_code):
return self._fetch_hk_data(stock_code, start_date, end_date)
elif _is_etf_code(stock_code):
return self._fetch_etf_data(stock_code, start_date, end_date)
else:
return self._fetch_stock_data(stock_code, start_date, end_date)
def _fetch_stock_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
"""
获取普通 A 股历史数据
策略:
1. 优先尝试东方财富接口 (ak.stock_zh_a_hist)
2. 失败后尝试新浪财经接口 (ak.stock_zh_a_daily)
3. 最后尝试腾讯财经接口 (ak.stock_zh_a_hist_tx)
"""
# 尝试列表
methods = [
(self._fetch_stock_data_em, "东方财富"),
(self._fetch_stock_data_sina, "新浪财经"),
(self._fetch_stock_data_tx, "腾讯财经"),
]
last_error = None
for fetch_method, source_name in methods:
try:
logger.info(f"[数据源] 尝试使用 {source_name} 获取 {stock_code}...")
df = fetch_method(stock_code, start_date, end_date)
if df is not None and not df.empty:
logger.info(f"[数据源] {source_name} 获取成功")
return df
except Exception as e:
last_error = e
logger.warning(f"[数据源] {source_name} 获取失败: {e}")
# 继续尝试下一个
# 所有都失败
raise DataFetchError(f"Akshare 所有渠道获取失败: {last_error}")
def _fetch_stock_data_em(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
"""
获取普通 A 股历史数据 (东方财富)
数据来源ak.stock_zh_a_hist()
"""
import akshare as ak
# 防封禁策略 1: 随机 User-Agent
self._set_random_user_agent()
# 防封禁策略 2: 强制休眠
self._enforce_rate_limit()
logger.info(f"[API调用] ak.stock_zh_a_hist(symbol={stock_code}, ...)")
try:
import time as _time
api_start = _time.time()
df = ak.stock_zh_a_hist(
symbol=stock_code,
period="daily",
start_date=start_date.replace('-', ''),
end_date=end_date.replace('-', ''),
adjust="qfq"
)
api_elapsed = _time.time() - api_start
if df is not None and not df.empty:
logger.info(f"[API返回] ak.stock_zh_a_hist 成功: {len(df)} 行, 耗时 {api_elapsed:.2f}s")
return df
else:
logger.warning(f"[API返回] ak.stock_zh_a_hist 返回空数据")
return pd.DataFrame()
except Exception as e:
error_msg = str(e).lower()
if any(keyword in error_msg for keyword in ['banned', 'blocked', '频率', 'rate', '限制']):
raise RateLimitError(f"Akshare(EM) 可能被限流: {e}") from e
raise e
def _fetch_stock_data_sina(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
"""
获取普通 A 股历史数据 (新浪财经)
数据来源ak.stock_zh_a_daily()
"""
import akshare as ak
# 转换代码格式sh600000, sz000001
if stock_code.startswith(('6', '5', '9')):
symbol = f"sh{stock_code}"
else:
symbol = f"sz{stock_code}"
self._enforce_rate_limit()
try:
df = ak.stock_zh_a_daily(
symbol=symbol,
start_date=start_date.replace('-', ''),
end_date=end_date.replace('-', ''),
adjust="qfq"
)
# 标准化新浪数据列名
# 新浪返回date, open, high, low, close, volume, amount, outstanding_share, turnover
if df is not None and not df.empty:
# 确保日期列存在
if 'date' in df.columns:
df = df.rename(columns={'date': '日期'})
# 映射其他列以匹配 _normalize_data 的期望
# _normalize_data 期望:日期, 开盘, 收盘, 最高, 最低, 成交量, 成交额
rename_map = {
'open': '开盘', 'high': '最高', 'low': '最低',
'close': '收盘', 'volume': '成交量', 'amount': '成交额'
}
df = df.rename(columns=rename_map)
# 计算涨跌幅(新浪接口可能不返回)
if '收盘' in df.columns:
df['涨跌幅'] = df['收盘'].pct_change() * 100
df['涨跌幅'] = df['涨跌幅'].fillna(0)
return df
return pd.DataFrame()
except Exception as e:
raise e
def _fetch_stock_data_tx(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
"""
获取普通 A 股历史数据 (腾讯财经)
数据来源ak.stock_zh_a_hist_tx()
"""
import akshare as ak
# 转换代码格式sh600000, sz000001
if stock_code.startswith(('6', '5', '9')):
symbol = f"sh{stock_code}"
else:
symbol = f"sz{stock_code}"
self._enforce_rate_limit()
try:
df = ak.stock_zh_a_hist_tx(
symbol=symbol,
start_date=start_date.replace('-', ''),
end_date=end_date.replace('-', ''),
adjust="qfq"
)
# 标准化腾讯数据列名
# 腾讯返回date, open, close, high, low, volume, amount
if df is not None and not df.empty:
rename_map = {
'date': '日期', 'open': '开盘', 'high': '最高',
'low': '最低', 'close': '收盘', 'volume': '成交量',
'amount': '成交额'
}
df = df.rename(columns=rename_map)
# 腾讯数据通常包含 '涨跌幅',如果没有则计算
if 'pct_chg' in df.columns:
df = df.rename(columns={'pct_chg': '涨跌幅'})
elif '收盘' in df.columns:
df['涨跌幅'] = df['收盘'].pct_change() * 100
df['涨跌幅'] = df['涨跌幅'].fillna(0)
return df
return pd.DataFrame()
except Exception as e:
raise e
def _fetch_etf_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
"""
获取 ETF 基金历史数据
数据来源ak.fund_etf_hist_em()
Args:
stock_code: ETF 代码,如 '512400', '159883'
start_date: 开始日期,格式 'YYYY-MM-DD'
end_date: 结束日期,格式 'YYYY-MM-DD'
Returns:
ETF 历史数据 DataFrame
"""
import akshare as ak
# 防封禁策略 1: 随机 User-Agent
self._set_random_user_agent()
# 防封禁策略 2: 强制休眠
self._enforce_rate_limit()
logger.info(f"[API调用] ak.fund_etf_hist_em(symbol={stock_code}, period=daily, "
f"start_date={start_date.replace('-', '')}, end_date={end_date.replace('-', '')}, adjust=qfq)")
try:
import time as _time
api_start = _time.time()
# 调用 akshare 获取 ETF 日线数据
df = ak.fund_etf_hist_em(
symbol=stock_code,
period="daily",
start_date=start_date.replace('-', ''),
end_date=end_date.replace('-', ''),
adjust="qfq" # 前复权
)
api_elapsed = _time.time() - api_start
# 记录返回数据摘要
if df is not None and not df.empty:
logger.info(f"[API返回] ak.fund_etf_hist_em 成功: 返回 {len(df)} 行数据, 耗时 {api_elapsed:.2f}s")
logger.info(f"[API返回] 列名: {list(df.columns)}")
logger.info(f"[API返回] 日期范围: {df['日期'].iloc[0]} ~ {df['日期'].iloc[-1]}")
logger.debug(f"[API返回] 最新3条数据:\n{df.tail(3).to_string()}")
else:
logger.warning(f"[API返回] ak.fund_etf_hist_em 返回空数据, 耗时 {api_elapsed:.2f}s")
return df
except Exception as e:
error_msg = str(e).lower()
# 检测反爬封禁
if any(keyword in error_msg for keyword in ['banned', 'blocked', '频率', 'rate', '限制']):
logger.warning(f"检测到可能被封禁: {e}")
raise RateLimitError(f"Akshare 可能被限流: {e}") from e
raise DataFetchError(f"Akshare 获取 ETF 数据失败: {e}") from e
def _fetch_us_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
"""
获取美股历史数据
数据来源ak.stock_us_daily()(新浪财经接口)
Args:
stock_code: 美股代码,如 'AMD', 'AAPL', 'TSLA'
start_date: 开始日期,格式 'YYYY-MM-DD'
end_date: 结束日期,格式 'YYYY-MM-DD'
Returns:
美股历史数据 DataFrame
"""
import akshare as ak
# 防封禁策略 1: 随机 User-Agent
self._set_random_user_agent()
# 防封禁策略 2: 强制休眠
self._enforce_rate_limit()
# 美股代码直接使用大写
symbol = stock_code.strip().upper()
logger.info(f"[API调用] ak.stock_us_daily(symbol={symbol}, adjust=qfq)")
try:
import time as _time
api_start = _time.time()
# 调用 akshare 获取美股日线数据
# stock_us_daily 返回全部历史数据,后续需要按日期过滤
df = ak.stock_us_daily(
symbol=symbol,
adjust="qfq" # 前复权
)
api_elapsed = _time.time() - api_start
# 记录返回数据摘要
if df is not None and not df.empty:
logger.info(f"[API返回] ak.stock_us_daily 成功: 返回 {len(df)} 行数据, 耗时 {api_elapsed:.2f}s")
logger.info(f"[API返回] 列名: {list(df.columns)}")
# 按日期过滤
df['date'] = pd.to_datetime(df['date'])
start_dt = pd.to_datetime(start_date)
end_dt = pd.to_datetime(end_date)
df = df[(df['date'] >= start_dt) & (df['date'] <= end_dt)]
if not df.empty:
logger.info(f"[API返回] 过滤后日期范围: {df['date'].iloc[0].strftime('%Y-%m-%d')} ~ {df['date'].iloc[-1].strftime('%Y-%m-%d')}")
logger.debug(f"[API返回] 最新3条数据:\n{df.tail(3).to_string()}")
else:
logger.warning(f"[API返回] 过滤后数据为空,日期范围 {start_date} ~ {end_date} 无数据")
# 转换列名为中文格式以匹配 _normalize_data
# stock_us_daily 返回: date, open, high, low, close, volume
rename_map = {
'date': '日期',
'open': '开盘',
'high': '最高',
'low': '最低',
'close': '收盘',
'volume': '成交量',
}
df = df.rename(columns=rename_map)
# 计算涨跌幅(美股接口不直接返回)
if '收盘' in df.columns:
df['涨跌幅'] = df['收盘'].pct_change() * 100
df['涨跌幅'] = df['涨跌幅'].fillna(0)
# 估算成交额(美股接口不返回)
if '成交量' in df.columns and '收盘' in df.columns:
df['成交额'] = df['成交量'] * df['收盘']
else:
df['成交额'] = 0
return df
else:
logger.warning(f"[API返回] ak.stock_us_daily 返回空数据, 耗时 {api_elapsed:.2f}s")
return pd.DataFrame()
except Exception as e:
error_msg = str(e).lower()
# 检测反爬封禁
if any(keyword in error_msg for keyword in ['banned', 'blocked', '频率', 'rate', '限制']):
logger.warning(f"检测到可能被封禁: {e}")
raise RateLimitError(f"Akshare 可能被限流: {e}") from e
raise DataFetchError(f"Akshare 获取美股数据失败: {e}") from e
def _fetch_hk_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
"""
获取港股历史数据
数据来源ak.stock_hk_hist()
Args:
stock_code: 港股代码,如 '00700', '01810'
start_date: 开始日期,格式 'YYYY-MM-DD'
end_date: 结束日期,格式 'YYYY-MM-DD'
Returns:
港股历史数据 DataFrame
"""
import akshare as ak
# 防封禁策略 1: 随机 User-Agent
self._set_random_user_agent()
# 防封禁策略 2: 强制休眠
self._enforce_rate_limit()
# 确保代码格式正确5位数字
code = stock_code.lower().replace('hk', '').zfill(5)
logger.info(f"[API调用] ak.stock_hk_hist(symbol={code}, period=daily, "
f"start_date={start_date.replace('-', '')}, end_date={end_date.replace('-', '')}, adjust=qfq)")
try:
import time as _time
api_start = _time.time()
# 调用 akshare 获取港股日线数据
df = ak.stock_hk_hist(
symbol=code,
period="daily",
start_date=start_date.replace('-', ''),
end_date=end_date.replace('-', ''),
adjust="qfq" # 前复权
)
api_elapsed = _time.time() - api_start
# 记录返回数据摘要
if df is not None and not df.empty:
logger.info(f"[API返回] ak.stock_hk_hist 成功: 返回 {len(df)} 行数据, 耗时 {api_elapsed:.2f}s")
logger.info(f"[API返回] 列名: {list(df.columns)}")
logger.info(f"[API返回] 日期范围: {df['日期'].iloc[0]} ~ {df['日期'].iloc[-1]}")
logger.debug(f"[API返回] 最新3条数据:\n{df.tail(3).to_string()}")
else:
logger.warning(f"[API返回] ak.stock_hk_hist 返回空数据, 耗时 {api_elapsed:.2f}s")
return df
except Exception as e:
error_msg = str(e).lower()
# 检测反爬封禁
if any(keyword in error_msg for keyword in ['banned', 'blocked', '频率', 'rate', '限制']):
logger.warning(f"检测到可能被封禁: {e}")
raise RateLimitError(f"Akshare 可能被限流: {e}") from e
raise DataFetchError(f"Akshare 获取港股数据失败: {e}") from e
def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
"""
标准化 Akshare 数据
Akshare 返回的列名(中文):
日期, 开盘, 收盘, 最高, 最低, 成交量, 成交额, 振幅, 涨跌幅, 涨跌额, 换手率
需要映射到标准列名:
date, open, high, low, close, volume, amount, pct_chg
"""
df = df.copy()
# 列名映射Akshare 中文列名 -> 标准英文列名)
column_mapping = {
'日期': 'date',
'开盘': 'open',
'收盘': 'close',
'最高': 'high',
'最低': 'low',
'成交量': 'volume',
'成交额': 'amount',
'涨跌幅': 'pct_chg',
}
# 重命名列
df = df.rename(columns=column_mapping)
# 添加股票代码列
df['code'] = stock_code
# 只保留需要的列
keep_cols = ['code'] + STANDARD_COLUMNS
existing_cols = [col for col in keep_cols if col in df.columns]
df = df[existing_cols]
return df
def get_realtime_quote(self, stock_code: str, source: str = "em") -> Optional[UnifiedRealtimeQuote]:
"""
获取实时行情数据(支持多数据源)
数据源优先级(可配置):
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:
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 处理
logger.debug(f"[API跳过] {stock_code} 是美股Akshare 不支持美股实时行情")
return None
elif _is_hk_code(stock_code):
return self._get_hk_realtime_quote(stock_code)
elif _is_etf_code(stock_code):
return self._get_etf_realtime_quote(stock_code)
else:
# 普通 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_em(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]:
"""
获取普通 A 股实时行情数据(东方财富数据源)
数据来源ak.stock_zh_a_spot_em()
优点:数据最全,含量比、换手率、市盈率、市净率、总市值、流通市值等
缺点:全量拉取,数据量大,容易超时/限流
"""
import akshare as ak
circuit_breaker = get_realtime_circuit_breaker()
source_key = "akshare_em"
try:
# 检查缓存
current_time = time.time()
if (_realtime_cache['data'] is not None and
current_time - _realtime_cache['timestamp'] < _realtime_cache['ttl']):
df = _realtime_cache['data']
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):
try:
# 防封禁策略
self._set_random_user_agent()
self._enforce_rate_limit()
logger.info(f"[API调用] ak.stock_zh_a_spot_em() 获取A股实时行情... (attempt {attempt}/2)")
import time as _time
api_start = _time.time()
df = ak.stock_zh_a_spot_em()
api_elapsed = _time.time() - api_start
logger.info(f"[API返回] ak.stock_zh_a_spot_em 成功: 返回 {len(df)} 只股票, 耗时 {api_elapsed:.2f}s")
circuit_breaker.record_success(source_key)
break
except Exception as e:
last_error = e
logger.warning(f"[API错误] ak.stock_zh_a_spot_em 获取失败 (attempt {attempt}/2): {e}")
time.sleep(min(2 ** attempt, 5))
# 更新缓存:成功缓存数据;失败也缓存空数据,避免同一轮任务对同一接口反复请求
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}")
return None
# 查找指定股票
row = df[df['代码'] == stock_code]
if row.empty:
logger.warning(f"[API返回] 未找到股票 {stock_code} 的实时行情")
return None
row = row.iloc[0]
# 使用 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('总市值')),
circ_mv=safe_float(row.get('流通市值')),
change_60d=safe_float(row.get('60日涨跌幅')),
high_52w=safe_float(row.get('52周最高')),
low_52w=safe_float(row.get('52周最低')),
)
logger.info(f"[实时行情-东财] {stock_code} {quote.name}: 价格={quote.price}, 涨跌={quote.change_pct}%, "
f"量比={quote.volume_ratio}, 换手率={quote.turnover_rate}%")
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_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-28:买卖五档 30:时间戳 31:涨跌额 32:涨跌幅(%) 33:今开 34:最高 35:最低/成交量/成交额
# 36:成交量(手) 37:成交额(万) 38:换手率(%) 39:市盈率 43:振幅(%)
# 44:流通市值(亿) 45:总市值(亿) 46:市净率 47:涨停价 48:跌停价 49:量比
# 使用 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[34]) if len(fields) > 34 else None,
low=safe_float(fields[35].split('/')[0]) if len(fields) > 35 and '/' in str(fields[35]) else safe_float(fields[35]) if len(fields) > 35 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,
volume_ratio=safe_float(fields[49]) if len(fields) > 49 else None, # 量比
pe_ratio=safe_float(fields[39]) if len(fields) > 39 else None, # 市盈率
pb_ratio=safe_float(fields[46]) if len(fields) > 46 else None, # 市净率
circ_mv=safe_float(fields[44]) * 100000000 if len(fields) > 44 and fields[44] else None, # 流通市值(亿->元)
total_mv=safe_float(fields[45]) * 100000000 if len(fields) > 45 and fields[45] else None, # 总市值(亿->元)
)
logger.info(f"[实时行情-腾讯] {stock_code} {quote.name}: 价格={quote.price}, "
f"涨跌={quote.change_pct}%, 量比={quote.volume_ratio}, 换手率={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 基金实时行情数据
数据来源ak.fund_etf_spot_em()
包含:最新价、涨跌幅、成交量、成交额、换手率等
Args:
stock_code: ETF 代码
Returns:
UnifiedRealtimeQuote 对象,获取失败返回 None
"""
import akshare as ak
circuit_breaker = get_realtime_circuit_breaker()
source_key = "akshare_etf"
try:
# 检查缓存
current_time = time.time()
if (_etf_realtime_cache['data'] is not None and
current_time - _etf_realtime_cache['timestamp'] < _etf_realtime_cache['ttl']):
df = _etf_realtime_cache['data']
logger.debug(f"[缓存命中] 使用缓存的ETF实时行情数据")
else:
last_error: Optional[Exception] = None
df = None
for attempt in range(1, 3):
try:
# 防封禁策略
self._set_random_user_agent()
self._enforce_rate_limit()
logger.info(f"[API调用] ak.fund_etf_spot_em() 获取ETF实时行情... (attempt {attempt}/2)")
import time as _time
api_start = _time.time()
df = ak.fund_etf_spot_em()
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
logger.warning(f"[API错误] ak.fund_etf_spot_em 获取失败 (attempt {attempt}/2): {e}")
time.sleep(min(2 ** attempt, 5))
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
if df is None or df.empty:
logger.warning(f"[实时行情] ETF实时行情数据为空跳过 {stock_code}")
return None
# 查找指定 ETF
row = df[df['代码'] == stock_code]
if row.empty:
logger.warning(f"[API返回] 未找到 ETF {stock_code} 的实时行情")
return None
row = row.iloc[0]
# 使用 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=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('最低')),
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}%, "
f"换手率={quote.turnover_rate}%")
return quote
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[UnifiedRealtimeQuote]:
"""
获取港股实时行情数据
数据来源ak.stock_hk_spot_em()
包含:最新价、涨跌幅、成交量、成交额等
Args:
stock_code: 港股代码
Returns:
UnifiedRealtimeQuote 对象,获取失败返回 None
"""
import akshare as ak
circuit_breaker = get_realtime_circuit_breaker()
source_key = "akshare_hk"
try:
# 防封禁策略
self._set_random_user_agent()
self._enforce_rate_limit()
# 确保代码格式正确5位数字
code = stock_code.lower().replace('hk', '').zfill(5)
logger.info(f"[API调用] ak.stock_hk_spot_em() 获取港股实时行情...")
import time as _time
api_start = _time.time()
df = ak.stock_hk_spot_em()
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]
if row.empty:
logger.warning(f"[API返回] 未找到港股 {code} 的实时行情")
return None
row = row.iloc[0]
# 使用 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('振幅')),
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}%, "
f"换手率={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_chip_distribution(self, stock_code: str) -> Optional[ChipDistribution]:
"""
获取筹码分布数据
数据来源ak.stock_cyq_em()
包含:获利比例、平均成本、筹码集中度
注意ETF/指数没有筹码分布数据,会直接返回 None
Args:
stock_code: 股票代码
Returns:
ChipDistribution 对象(最新一天的数据),获取失败返回 None
"""
import akshare as ak
# 美股没有筹码分布数据Akshare 不支持)
if _is_us_code(stock_code):
logger.debug(f"[API跳过] {stock_code} 是美股,无筹码分布数据")
return None
# ETF/指数没有筹码分布数据
if _is_etf_code(stock_code):
logger.debug(f"[API跳过] {stock_code} 是 ETF/指数,无筹码分布数据")
return None
try:
# 防封禁策略
self._set_random_user_agent()
self._enforce_rate_limit()
logger.info(f"[API调用] ak.stock_cyq_em(symbol={stock_code}) 获取筹码分布...")
import time as _time
api_start = _time.time()
df = ak.stock_cyq_em(symbol=stock_code)
api_elapsed = _time.time() - api_start
if df.empty:
logger.warning(f"[API返回] ak.stock_cyq_em 返回空数据, 耗时 {api_elapsed:.2f}s")
return None
logger.info(f"[API返回] ak.stock_cyq_em 成功: 返回 {len(df)} 天数据, 耗时 {api_elapsed:.2f}s")
logger.debug(f"[API返回] 筹码数据列名: {list(df.columns)}")
# 取最新一天的数据
latest = df.iloc[-1]
# 使用 realtime_types.py 中的统一转换函数
chip = ChipDistribution(
code=stock_code,
date=str(latest.get('日期', '')),
profit_ratio=safe_float(latest.get('获利比例')),
avg_cost=safe_float(latest.get('平均成本')),
cost_90_low=safe_float(latest.get('90成本-低')),
cost_90_high=safe_float(latest.get('90成本-高')),
concentration_90=safe_float(latest.get('90集中度')),
cost_70_low=safe_float(latest.get('70成本-低')),
cost_70_high=safe_float(latest.get('70成本-高')),
concentration_70=safe_float(latest.get('70集中度')),
)
logger.info(f"[筹码分布] {stock_code} 日期={chip.date}: 获利比例={chip.profit_ratio:.1%}, "
f"平均成本={chip.avg_cost}, 90%集中度={chip.concentration_90:.2%}, "
f"70%集中度={chip.concentration_70:.2%}")
return chip
except Exception as e:
logger.error(f"[API错误] 获取 {stock_code} 筹码分布失败: {e}")
return None
def get_enhanced_data(self, stock_code: str, days: int = 60) -> Dict[str, Any]:
"""
获取增强数据历史K线 + 实时行情 + 筹码分布)
Args:
stock_code: 股票代码
days: 历史数据天数
Returns:
包含所有数据的字典
"""
result = {
'code': stock_code,
'daily_data': None,
'realtime_quote': None,
'chip_distribution': None,
}
# 获取日线数据
try:
df = self.get_daily_data(stock_code, days=days)
result['daily_data'] = df
except Exception as e:
logger.error(f"获取 {stock_code} 日线数据失败: {e}")
# 获取实时行情
result['realtime_quote'] = self.get_realtime_quote(stock_code)
# 获取筹码分布
result['chip_distribution'] = self.get_chip_distribution(stock_code)
return result
def get_main_indices(self) -> Optional[List[Dict[str, Any]]]:
"""
获取主要指数实时行情 (新浪接口)
"""
import akshare as ak
# 主要指数代码映射
indices_map = {
'sh000001': '上证指数',
'sz399001': '深证成指',
'sz399006': '创业板指',
'sh000688': '科创50',
'sh000016': '上证50',
'sh000300': '沪深300',
}
try:
self._set_random_user_agent()
self._enforce_rate_limit()
# 使用 akshare 获取指数行情(新浪财经接口)
df = ak.stock_zh_index_spot_sina()
results = []
if df is not None and not df.empty:
for code, name in indices_map.items():
# 查找对应指数
row = df[df['代码'] == code]
if row.empty:
# 尝试带前缀查找
row = df[df['代码'].str.contains(code)]
if not row.empty:
row = row.iloc[0]
current = safe_float(row.get('最新价', 0))
prev_close = safe_float(row.get('昨收', 0))
high = safe_float(row.get('最高', 0))
low = safe_float(row.get('最低', 0))
# 计算振幅
amplitude = 0.0
if prev_close > 0:
amplitude = (high - low) / prev_close * 100
results.append({
'code': code,
'name': name,
'current': current,
'change': safe_float(row.get('涨跌额', 0)),
'change_pct': safe_float(row.get('涨跌幅', 0)),
'open': safe_float(row.get('今开', 0)),
'high': high,
'low': low,
'prev_close': prev_close,
'volume': safe_float(row.get('成交量', 0)),
'amount': safe_float(row.get('成交额', 0)),
'amplitude': amplitude,
})
return results
except Exception as e:
logger.error(f"[Akshare] 获取指数行情失败: {e}")
return None
def get_market_stats(self) -> Optional[Dict[str, Any]]:
"""
获取市场涨跌统计
数据源优先级:
1. 东财接口 (ak.stock_zh_a_spot_em)
2. 新浪接口 (ak.stock_zh_a_spot)
"""
import akshare as ak
# 优先东财接口
try:
self._set_random_user_agent()
self._enforce_rate_limit()
logger.info("[API调用] ak.stock_zh_a_spot_em() 获取市场统计...")
df = ak.stock_zh_a_spot_em()
if df is not None and not df.empty:
return self._calc_market_stats(df, change_col='涨跌幅', amount_col='成交额')
except Exception as e:
logger.warning(f"[Akshare] 东财接口获取市场统计失败: {e},尝试新浪接口")
# 东财失败后,尝试新浪接口
try:
self._set_random_user_agent()
self._enforce_rate_limit()
logger.info("[API调用] ak.stock_zh_a_spot() 获取市场统计(新浪)...")
df = ak.stock_zh_a_spot()
if df is not None and not df.empty:
change_col = None
for col in ['change_percent', 'changepercent', '涨跌幅', 'trade_ratio']:
if col in df.columns:
change_col = col
break
amount_col = None
for col in ['amount', '成交额', 'trade_amount']:
if col in df.columns:
amount_col = col
break
if change_col:
return self._calc_market_stats(df, change_col=change_col, amount_col=amount_col)
except Exception as e:
logger.error(f"[Akshare] 新浪接口获取市场统计也失败: {e}")
return None
def _calc_market_stats(
self,
df: pd.DataFrame,
change_col: str,
amount_col: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""从行情 DataFrame 计算涨跌统计。"""
if change_col not in df.columns:
return None
df[change_col] = pd.to_numeric(df[change_col], errors='coerce')
stats = {
'up_count': len(df[df[change_col] > 0]),
'down_count': len(df[df[change_col] < 0]),
'flat_count': len(df[df[change_col] == 0]),
'limit_up_count': len(df[df[change_col] >= 9.9]),
'limit_down_count': len(df[df[change_col] <= -9.9]),
'total_amount': 0.0,
}
if amount_col and amount_col in df.columns:
df[amount_col] = pd.to_numeric(df[amount_col], errors='coerce')
stats['total_amount'] = df[amount_col].sum() / 1e8
return stats
def get_sector_rankings(self, n: int = 5) -> Optional[Tuple[List[Dict], List[Dict]]]:
"""
获取板块涨跌榜
数据源优先级:
1. 东财接口 (ak.stock_board_industry_name_em)
2. 新浪接口 (ak.stock_sector_spot)
"""
import akshare as ak
# 优先东财接口
try:
self._set_random_user_agent()
self._enforce_rate_limit()
logger.info("[API调用] ak.stock_board_industry_name_em() 获取板块排行...")
df = ak.stock_board_industry_name_em()
if df is not None and not df.empty:
change_col = '涨跌幅'
if change_col in df.columns:
df[change_col] = pd.to_numeric(df[change_col], errors='coerce')
df = df.dropna(subset=[change_col])
# 涨幅前n
top = df.nlargest(n, change_col)
top_sectors = [
{'name': row['板块名称'], 'change_pct': row[change_col]}
for _, row in top.iterrows()
]
bottom = df.nsmallest(n, change_col)
bottom_sectors = [
{'name': row['板块名称'], 'change_pct': row[change_col]}
for _, row in bottom.iterrows()
]
return top_sectors, bottom_sectors
except Exception as e:
logger.warning(f"[Akshare] 东财接口获取板块排行失败: {e},尝试新浪接口")
# 东财失败后,尝试新浪接口
try:
self._set_random_user_agent()
self._enforce_rate_limit()
logger.info("[API调用] ak.stock_sector_spot() 获取板块排行(新浪)...")
df = ak.stock_sector_spot(indicator='新浪行业')
if df is None or df.empty:
return None
change_col = None
for col in ['涨跌幅', 'change_pct', '涨幅']:
if col in df.columns:
change_col = col
break
name_col = None
for col in ['板块', '板块名称', 'label', 'name']:
if col in df.columns:
name_col = col
break
if not change_col or not name_col:
return None
df[change_col] = pd.to_numeric(df[change_col], errors='coerce')
df = df.dropna(subset=[change_col])
top = df.nlargest(n, change_col)
bottom = df.nsmallest(n, change_col)
top_sectors = [
{'name': str(row[name_col]), 'change_pct': float(row[change_col])}
for _, row in top.iterrows()
]
bottom_sectors = [
{'name': str(row[name_col]), 'change_pct': float(row[change_col])}
for _, row in bottom.iterrows()
]
return top_sectors, bottom_sectors
except Exception as e:
logger.error(f"[Akshare] 新浪接口获取板块排行也失败: {e}")
return None
if __name__ == "__main__":
# 测试代码
logging.basicConfig(level=logging.DEBUG)
fetcher = AkshareFetcher()
# 测试普通股票
print("=" * 50)
print("测试普通股票数据获取")
print("=" * 50)
try:
df = fetcher.get_daily_data('600519') # 茅台
print(f"[股票] 获取成功,共 {len(df)} 条数据")
print(df.tail())
except Exception as e:
print(f"[股票] 获取失败: {e}")
# 测试 ETF 基金
print("\n" + "=" * 50)
print("测试 ETF 基金数据获取")
print("=" * 50)
try:
df = fetcher.get_daily_data('512400') # 有色龙头ETF
print(f"[ETF] 获取成功,共 {len(df)} 条数据")
print(df.tail())
except Exception as e:
print(f"[ETF] 获取失败: {e}")
# 测试 ETF 实时行情
print("\n" + "=" * 50)
print("测试 ETF 实时行情获取")
print("=" * 50)
try:
quote = fetcher.get_realtime_quote('512880') # 证券ETF
if quote:
print(f"[ETF实时] {quote.name}: 价格={quote.price}, 涨跌幅={quote.change_pct}%")
else:
print("[ETF实时] 未获取到数据")
except Exception as e:
print(f"[ETF实时] 获取失败: {e}")
# 测试港股历史数据
print("\n" + "=" * 50)
print("测试港股历史数据获取")
print("=" * 50)
try:
df = fetcher.get_daily_data('00700') # 腾讯控股
print(f"[港股] 获取成功,共 {len(df)} 条数据")
print(df.tail())
except Exception as e:
print(f"[港股] 获取失败: {e}")
# 测试港股实时行情
print("\n" + "=" * 50)
print("测试港股实时行情获取")
print("=" * 50)
try:
quote = fetcher.get_realtime_quote('00700') # 腾讯控股
if quote:
print(f"[港股实时] {quote.name}: 价格={quote.price}, 涨跌幅={quote.change_pct}%")
else:
print("[港股实时] 未获取到数据")
except Exception as e:
print(f"[港股实时] 获取失败: {e}")