Files
Chan/web/services/cn_stock.py
jackyu66gitandCursor 74dec4e50b refactor: 缠论引擎包化与 Web 分层(ECR-001)
将根目录引擎迁入 chanlun/ 并保留兼容 shim;拆分 TF_DF 与 web 服务;
前端模块化;strategies 改用 chanlun 导入;补充 ESS 文档与 golden 回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:48:20 +08:00

966 lines
41 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import akshare as ak
import pandas as pd
from datetime import datetime, timedelta, time
import time as time_module
import traceback
from pytz import timezone
import warnings
warnings.filterwarnings('ignore')
import logging
logger = logging.getLogger(__name__)
# 与 A-Share Data Platform REST 文档一致的周期(分钟线依赖服务端积累,无数据时会回退 AKShare)
ASHARE_REST_TIMEFRAMES = frozenset({'1m', '5m', '15m', '30m', '1h', '2h', '1d', '1w', '1M'})
class ChinaStockData:
"""A股数据获取类"""
def __init__(self):
self.tz = timezone('Asia/Shanghai')
# A股交易时间配置
self.trading_hours = {
'morning': {'start': '09:30', 'end': '11:30'},
'afternoon': {'start': '13:00', 'end': '15:00'}
}
# 例: http://103.179.242.166:8000 — 设 ASHARE_DP_URL= 空字符串可禁用,仅用 AKShare
_base = os.environ.get('ASHARE_DP_URL', 'http://103.179.242.166:8000')
self.ashare_dp_base = _base.rstrip('/') if (_base or '').strip() else ''
# 全量股票列表内存缓存(秒),默认 1 小时
try:
self.stock_list_cache_ttl = int(os.environ.get('ASHARE_STOCK_LIST_CACHE_SEC', '3600'))
except ValueError:
self.stock_list_cache_ttl = 3600
self._stock_list_cache = None
self._stock_list_cache_expires = 0.0
def _get_stock_list_akshare(self):
"""通过 AKShare 获取 A 股列表(约 2000 条非 ST,作备用)。"""
try:
import requests
try:
original_timeout = getattr(requests, 'timeout', None)
requests.timeout = 10
stock_info = ak.stock_zh_a_spot_em()
if original_timeout:
requests.timeout = original_timeout
else:
delattr(requests, 'timeout')
except Exception:
return []
if stock_info is None or len(stock_info) == 0:
return []
stock_list = []
for index, row in stock_info.head(2000).iterrows():
try:
stock_name = str(row['名称'])
if 'ST' not in stock_name and '*' not in stock_name:
stock_list.append({
'symbol': row['代码'],
'name': row['名称'],
'price': float(row['最新价']) if pd.notna(row['最新价']) else 0.0,
'change_pct': float(row['涨跌幅']) if pd.notna(row['涨跌幅']) else 0.0,
'volume': float(row['成交量']) if pd.notna(row['成交量']) else 0.0,
'amount': float(row['成交额']) if pd.notna(row['成交额']) else 0.0
})
except Exception:
continue
stock_list.sort(key=lambda x: x['amount'], reverse=True)
return stock_list
except Exception:
return []
def _fetch_all_stocks_ashare_dp(self):
"""分页拉取 A-Share Data Platform /api/v1/stocks 全市场标的。"""
import requests
page_size = 1000
offset = 0
all_rows = []
reported_total = None
url = f'{self.ashare_dp_base}/api/v1/stocks'
while True:
resp = requests.get(
url,
params={'limit': page_size, 'offset': offset},
timeout=45,
)
resp.raise_for_status()
payload = resp.json()
items = payload.get('items') or []
if reported_total is None:
reported_total = int(payload.get('total') or 0)
all_rows.extend(items)
if len(items) == 0:
break
if len(items) < page_size:
break
offset += page_size
if reported_total and offset >= reported_total:
break
if not all_rows:
return []
out = []
for row in all_rows:
sym = row.get('symbol')
if not sym and row.get('ts_code'):
sym = str(row['ts_code']).split('.')[0]
if not sym:
continue
name = row.get('name') or ''
out.append({
'symbol': str(sym).strip(),
'name': str(name).strip(),
'ts_code': row.get('ts_code'),
'price': 0.0,
'change_pct': 0.0,
'volume': 0.0,
'amount': 0.0,
})
out.sort(key=lambda x: x['symbol'])
return out
def get_stock_list(self, use_cache=True):
"""获取 A 股股票列表:优先全量 REST(约 5500+),失败则 AKShare。"""
now = time_module.time()
if use_cache and self._stock_list_cache is not None and now < self._stock_list_cache_expires:
return list(self._stock_list_cache)
if self.ashare_dp_base:
try:
dp_list = self._fetch_all_stocks_ashare_dp()
if dp_list:
self._stock_list_cache = dp_list
self._stock_list_cache_expires = now + self.stock_list_cache_ttl
return list(dp_list)
except Exception as exc:
logger.warning('A股列表从数据服务拉取失败,回退 AKShare: %s', exc)
ak_list = self._get_stock_list_akshare()
if ak_list:
self._stock_list_cache = ak_list
self._stock_list_cache_expires = now + min(self.stock_list_cache_ttl, 300)
return ak_list or []
def get_available_kline_freqs(self):
"""
A-Share Data Platform 支持的 K 线周期列表(原始顺序不保证,由上层按粒度排序)。
文档: GET /api/v1/klines/available-freqs
"""
import requests
fallback = ['1m', '5m', '15m', '30m', '1h', '2h', '1d', '1w', '1M']
if not self.ashare_dp_base:
return list(fallback)
try:
url = f'{self.ashare_dp_base}/api/v1/klines/available-freqs'
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
freqs = data.get('frequencies') or []
return list(freqs) if freqs else list(fallback)
except Exception as exc:
logger.warning('获取 A 股可用 K 线周期失败: %s', exc)
return list(fallback)
def get_popular_stocks(self):
"""获取热门A股股票代码列表 - 扩展版本,按行业分类"""
return [
# 包装引印刷
{'symbol': '002836', 'name': '新宏泽', 'sector': '包装印刷'},
# 银行股
{'symbol': '600036', 'name': '招商银行', 'sector': '银行'},
{'symbol': '000001', 'name': '平安银行', 'sector': '银行'},
{'symbol': '600000', 'name': '浦发银行', 'sector': '银行'},
{'symbol': '002142', 'name': '宁波银行', 'sector': '银行'},
{'symbol': '600016', 'name': '民生银行', 'sector': '银行'},
{'symbol': '601288', 'name': '农业银行', 'sector': '银行'},
{'symbol': '601398', 'name': '工商银行', 'sector': '银行'},
{'symbol': '601328', 'name': '交通银行', 'sector': '银行'},
# 白酒股
{'symbol': '600519', 'name': '贵州茅台', 'sector': '白酒'},
{'symbol': '000858', 'name': '五粮液', 'sector': '白酒'},
{'symbol': '002304', 'name': '洋河股份', 'sector': '白酒'},
{'symbol': '000596', 'name': '古井贡酒', 'sector': '白酒'},
{'symbol': '603369', 'name': '今世缘', 'sector': '白酒'},
{'symbol': '000799', 'name': '酒鬼酒', 'sector': '白酒'},
{'symbol': '600809', 'name': '山西汾酒', 'sector': '白酒'},
# 科技股
{'symbol': '002415', 'name': '海康威视', 'sector': '科技'},
{'symbol': '000063', 'name': '中兴通讯', 'sector': '科技'},
{'symbol': '002475', 'name': '立讯精密', 'sector': '科技'},
{'symbol': '300059', 'name': '东方财富', 'sector': '科技'},
{'symbol': '000725', 'name': '京东方A', 'sector': '科技'},
{'symbol': '002230', 'name': '科大讯飞', 'sector': '科技'},
{'symbol': '300433', 'name': '蓝思科技', 'sector': '科技'},
{'symbol': '002236', 'name': '大华股份', 'sector': '科技'},
# 新能源
{'symbol': '300750', 'name': '宁德时代', 'sector': '新能源'},
{'symbol': '002594', 'name': '比亚迪', 'sector': '新能源'},
{'symbol': '300274', 'name': '阳光电源', 'sector': '新能源'},
{'symbol': '002460', 'name': '赣锋锂业', 'sector': '新能源'},
{'symbol': '300014', 'name': '亿纬锂能', 'sector': '新能源'},
{'symbol': '600884', 'name': '杉杉股份', 'sector': '新能源'},
{'symbol': '002812', 'name': '恩捷股份', 'sector': '新能源'},
# 房地产
{'symbol': '000002', 'name': '万科A', 'sector': '房地产'},
{'symbol': '000858', 'name': '五粮液', 'sector': '房地产'},
{'symbol': '600048', 'name': '保利发展', 'sector': '房地产'},
{'symbol': '001979', 'name': '招商蛇口', 'sector': '房地产'},
{'symbol': '600606', 'name': '绿地控股', 'sector': '房地产'},
# 消费股
{'symbol': '600887', 'name': '伊利股份', 'sector': '消费'},
{'symbol': '000568', 'name': '泸州老窖', 'sector': '消费'},
{'symbol': '600600', 'name': '青岛啤酒', 'sector': '消费'},
{'symbol': '000895', 'name': '双汇发展', 'sector': '消费'},
{'symbol': '002304', 'name': '洋河股份', 'sector': '消费'},
{'symbol': '600779', 'name': '水井坊', 'sector': '消费'},
# 医药股
{'symbol': '600196', 'name': '复星医药', 'sector': '医药'},
{'symbol': '000661', 'name': '长春高新', 'sector': '医药'},
{'symbol': '300015', 'name': '爱尔眼科', 'sector': '医药'},
{'symbol': '002821', 'name': '凯莱英', 'sector': '医药'},
{'symbol': '300760', 'name': '迈瑞医疗', 'sector': '医药'},
{'symbol': '600276', 'name': '恒瑞医药', 'sector': '医药'},
# 证券股
{'symbol': '000776', 'name': '广发证券', 'sector': '证券'},
{'symbol': '600030', 'name': '中信证券', 'sector': '证券'},
{'symbol': '000166', 'name': '申万宏源', 'sector': '证券'},
{'symbol': '601688', 'name': '华泰证券', 'sector': '证券'},
{'symbol': '600837', 'name': '海通证券', 'sector': '证券'},
# 化工股
{'symbol': '600309', 'name': '万华化学', 'sector': '化工'},
{'symbol': '002352', 'name': '顺丰控股', 'sector': '化工'},
{'symbol': '600346', 'name': '恒力石化', 'sector': '化工'},
{'symbol': '000792', 'name': '盐湖股份', 'sector': '化工'},
# 汽车股
{'symbol': '600104', 'name': '上汽集团', 'sector': '汽车'},
{'symbol': '000625', 'name': '长安汽车', 'sector': '汽车'},
{'symbol': '601633', 'name': '长城汽车', 'sector': '汽车'},
{'symbol': '002049', 'name': '紫光国微', 'sector': '汽车'},
# 军工股
{'symbol': '002179', 'name': '中航光电', 'sector': '军工'},
{'symbol': '600893', 'name': '航发动力', 'sector': '军工'},
{'symbol': '000768', 'name': '中航飞机', 'sector': '军工'},
# 基建股
{'symbol': '601186', 'name': '中国铁建', 'sector': '基建'},
{'symbol': '601390', 'name': '中国中铁', 'sector': '基建'},
{'symbol': '000001', 'name': '平安银行', 'sector': '基建'},
# 煤炭股
{'symbol': '601225', 'name': '陕西煤业', 'sector': '煤炭'},
{'symbol': '600188', 'name': '兖矿能源', 'sector': '煤炭'},
{'symbol': '601898', 'name': '中煤能源', 'sector': '煤炭'},
# 钢铁股
{'symbol': '000717', 'name': '韶钢松山', 'sector': '钢铁'},
{'symbol': '600019', 'name': '宝钢股份', 'sector': '钢铁'},
{'symbol': '000708', 'name': '中信特钢', 'sector': '钢铁'},
]
def timeframe_to_period(self, timeframe):
"""将时间周期转换为akshare的period参数"""
mapping = {
'1m': '1', # 1分钟
'5m': '5', # 5分钟
'15m': '15', # 15分钟
'30m': '30', # 30分钟
'1h': '60', # 60分钟
'1d': 'daily', # 日线
'1w': 'weekly',# 周线
'1M': 'monthly'# 月线
}
return mapping.get(timeframe, 'daily')
@staticmethod
def symbol_to_ts_code(symbol):
"""六位代码或已是 ts_code000001.SZ)→ 交易所后缀。"""
if symbol is None:
return ''
s = str(symbol).strip().upper()
if '.' in s and s.count('.') == 1:
return s
if len(s) != 6 or not s.isdigit():
return s
if s.startswith('6'):
return f'{s}.SH'
if s.startswith(('0', '3')):
return f'{s}.SZ'
if s.startswith('920'):
return f'{s}.BJ'
if s.startswith(('8', '4')):
return f'{s}.BJ'
return f'{s}.SZ'
@staticmethod
def _ymd_compact_to_api_date(ymd_compact):
"""YYYYMMDD → YYYY-MM-DD"""
if not ymd_compact or len(ymd_compact) != 8:
return None
return f'{ymd_compact[:4]}-{ymd_compact[4:6]}-{ymd_compact[6:8]}'
def get_kl_data_from_ashare_dp(self, symbol, timeframe, start_date, end_date, limit):
"""
从 A-Share Data Platform/api/v1/klines/{freq})拉取 K 线。
start_date / end_date 为 YYYYMMDD 字符串。
"""
if not self.ashare_dp_base or timeframe not in ASHARE_REST_TIMEFRAMES:
return None
import requests
ts_code = self.symbol_to_ts_code(symbol)
if not ts_code or '.' not in ts_code:
return None
start_api = self._ymd_compact_to_api_date(start_date)
end_api = self._ymd_compact_to_api_date(end_date)
if not start_api or not end_api:
return None
api_limit = 10000
if limit is not None:
try:
api_limit = min(int(limit), 10000)
except (TypeError, ValueError):
api_limit = 10000
url = f'{self.ashare_dp_base}/api/v1/klines/{timeframe}'
params = {
'ts_code': ts_code,
'start_date': start_api,
'end_date': end_api,
'limit': api_limit,
}
try:
resp = requests.get(url, params=params, timeout=20)
resp.raise_for_status()
payload = resp.json()
except Exception as exc:
logger.debug('A股数据服务 K 线请求失败: %s', exc)
return None
items = payload.get('items') or payload.get('data') or []
if not items:
return None
rows = []
for row in items:
t = row.get('trade_time') or row.get('trade_date')
if not t:
continue
rows.append({
'date': t,
'open': row.get('open'),
'high': row.get('high'),
'low': row.get('low'),
'close': row.get('close'),
'volume': row.get('volume'),
})
if not rows:
return None
df = pd.DataFrame(rows)
df['date'] = pd.to_datetime(df['date'])
for col in ('open', 'high', 'low', 'close', 'volume'):
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors='coerce')
df = df.dropna(subset=['open', 'high', 'low', 'close'])
df = df.sort_values('date').reset_index(drop=True)
df = self.adjust_timestamp_for_trading_hours(df, timeframe)
df = self.clean_a_stock_data(df, timeframe)
if df is None or len(df) == 0:
return None
if limit is not None:
try:
lim = int(limit)
if len(df) > lim:
df = df.tail(lim).reset_index(drop=True)
except (TypeError, ValueError):
pass
elif len(df) > 10000:
df = df.tail(10000).reset_index(drop=True)
df = self.add_indicators(df)
return df
def get_kl_data(self, symbol, timeframe='1d', start_date=None, end_date=None, limit=10000):
"""
获取A股K线数据 - 支持分批次获取突破单次限制
:param symbol: 股票代码,如 '000001'
:param timeframe: 时间周期,如 '1d', '1h', '5m'
:param start_date: 开始日期,格式 'YYYY-MM-DD'
:param end_date: 结束日期,格式 'YYYY-MM-DD'
:param limit: 数据条数限制
:return: DataFrame
"""
try:
period = self.timeframe_to_period(timeframe)
# 处理时间参数
if start_date is None:
# 默认获取最近一年的数据
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y%m%d')
else:
# 将 YYYY-MM-DD 格式转换为 YYYYMMDD
if '-' in start_date:
start_date = start_date.replace('-', '')
if end_date is None:
end_date = datetime.now().strftime('%Y%m%d')
else:
if '-' in end_date:
end_date = end_date.replace('-', '')
if self.ashare_dp_base:
df_dp = self.get_kl_data_from_ashare_dp(
symbol, timeframe, start_date, end_date, limit
)
if df_dp is not None and len(df_dp) > 0:
return df_dp
# 分批次获取数据以突破单次限制
all_data = []
current_start = start_date
# 计算时间间隔(根据时间周期调整批次大小)
if period in ['1', '5', '15', '30']:
# 分钟级数据,每次获取7天
batch_days = 7
elif period == '60':
# 小时级数据,每次获取30天
batch_days = 30
else:
# 日线及以上,每次获取365天
batch_days = 365
max_iterations = 20 # 最大迭代次数,防止无限循环
iteration_count = 0
while current_start <= end_date and iteration_count < max_iterations:
iteration_count += 1
# 计算当前批次的结束时间
current_start_dt = datetime.strptime(current_start, '%Y%m%d')
current_end_dt = current_start_dt + timedelta(days=batch_days)
current_end = min(current_end_dt.strftime('%Y%m%d'), end_date)
pass
try:
# 根据时间周期选择不同的API
df_batch = None
if period in ['1', '5', '15', '30', '60']:
# 分钟级数据
df_batch = ak.stock_zh_a_hist_min_em(symbol=symbol, period=period,
start_date=current_start, end_date=current_end)
if df_batch is not None and len(df_batch) > 0:
# 重命名列
df_batch = df_batch.rename(columns={
'时间': 'date',
'开盘': 'open',
'收盘': 'close',
'最高': 'high',
'最低': 'low',
'成交量': 'volume'
})
else:
# 日线、周线、月线数据
df_batch = ak.stock_zh_a_hist(symbol=symbol, period=period,
start_date=current_start, end_date=current_end)
if df_batch is not None and len(df_batch) > 0:
# 重命名列
df_batch = df_batch.rename(columns={
'日期': 'date',
'开盘': 'open',
'收盘': 'close',
'最高': 'high',
'最低': 'low',
'成交量': 'volume'
})
if df_batch is not None and len(df_batch) > 0:
# 转换时间格式
df_batch['date'] = pd.to_datetime(df_batch['date'])
# 根据A股交易时间调整时间戳
df_batch = self.adjust_timestamp_for_trading_hours(df_batch, timeframe)
all_data.append(df_batch)
pass
except Exception as e:
# 继续下一个批次
pass
# 更新下一批次的开始时间
current_start = (current_end_dt + timedelta(days=1)).strftime('%Y%m%d')
# 防止API请求过于频繁
time_module.sleep(0.5)
# 合并所有批次的数据
if not all_data:
return None
# 合并DataFrame
df = pd.concat(all_data, ignore_index=True)
# 数据清洗和格式化
df = df.dropna() # 删除空值
df = df.drop_duplicates(subset=['date']) # 删除重复数据
df = df.sort_values('date').reset_index(drop=True) # 按时间排序
# A股特有的数据清理和时间处理
df = self.clean_a_stock_data(df, timeframe)
# 限制数据条数 - 只有在没有指定明确时间范围时才应用
# 如果用户指定了start_date和end_date,应该返回该时间范围内的所有数据
if limit is not None and len(df) > limit:
# 检查是否指定了明确的时间范围
if start_date and end_date:
# 如果指定了时间范围,优先返回完整的时间范围数据
if len(df) > 10000: # 防止数据量过大,设置一个合理的上限
df = df.tail(10000).reset_index(drop=True)
else:
# 如果没有指定时间范围,使用默认的limit限制
df = df.tail(limit).reset_index(drop=True)
elif limit is None and len(df) > 10000:
# 即使没有limit限制,也要防止数据量过大影响性能
df = df.tail(10000).reset_index(drop=True)
# 添加技术指标
df = self.add_indicators(df)
# 最终数据验证 - 确保没有NaN值
import numpy as np
# 检查并处理任何剩余的NaN值
if df.isnull().any().any():
# 对于数值列,用0填充NaN
numeric_cols = df.select_dtypes(include=[np.number]).columns
for col in numeric_cols:
if col in ['volume_ratio']:
df[col] = df[col].fillna(1.0)
else:
df[col] = df[col].fillna(0)
# 删除仍然包含NaN的行
df = df.dropna()
# 确保所有数值都是有限的
for col in df.select_dtypes(include=[np.number]).columns:
df[col] = df[col].replace([np.inf, -np.inf], 0 if col != 'volume_ratio' else 1.0)
return df
except Exception as e:
return None
def add_indicators(self, df):
"""添加技术指标"""
try:
import talib.abstract as ta
import numpy as np
# MACD指标
fast = 8
slow = 16
period = 6
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
df['macd'] = macd['macd'].fillna(0)
df['macdsignal'] = macd['macdsignal'].fillna(0)
df['macdhist'] = macd['macdhist'].fillna(0)
# 移动平均线
df['ma5'] = ta.MA(df, timeperiod=5).fillna(0)
df['ma10'] = ta.MA(df, timeperiod=10).fillna(0)
df['ma30'] = ta.EMA(df, timeperiod=30).fillna(0)
df['ma250'] = ta.MA(df, timeperiod=250).fillna(0)
# RSI指标
df['rsi'] = ta.RSI(df, timeperiod=14).fillna(0)
# 成交量指标
df['avg_volume'] = df['volume'].rolling(10).mean().fillna(0)
df['volume_ratio'] = (df['volume'] / df['avg_volume']).fillna(1.0)
# 处理Infinity和-Infinity值
df['volume_ratio'] = df['volume_ratio'].replace([float('inf'), float('-inf')], 1.0)
# 确保所有指标列都不包含NaN或无限值
indicator_columns = ['macd', 'macdsignal', 'macdhist', 'ma5', 'ma10', 'ma30', 'ma250', 'rsi', 'avg_volume', 'volume_ratio']
for col in indicator_columns:
if col in df.columns:
# 替换NaN、inf、-inf为合理的默认值
df[col] = df[col].replace([np.nan, np.inf, -np.inf], 0 if col != 'volume_ratio' else 1.0)
return df
except Exception as e:
return df
def search_stock(self, keyword):
"""搜索股票 - 支持代码和名称模糊搜索"""
try:
if not keyword or len(keyword.strip()) == 0:
return []
keyword = keyword.strip().upper()
results = []
# 从热门股票中搜索
popular_stocks = self.get_popular_stocks()
for stock in popular_stocks:
if (keyword in stock['symbol'] or
keyword.lower() in stock['name'].lower() or
stock['symbol'].startswith(keyword)):
results.append({
'symbol': stock['symbol'],
'name': stock['name'],
'sector': stock.get('sector', ''),
'source': '热门股票'
})
# 如果热门股票中找到的结果少于10个,从完整股票列表中搜索
if len(results) < 10:
try:
# 获取完整股票列表进行搜索
stock_info = ak.stock_zh_a_spot_em()
# 搜索前1000只活跃股票
for index, row in stock_info.head(1000).iterrows():
stock_code = str(row['代码'])
stock_name = str(row['名称'])
# 过滤ST股票
if 'ST' in stock_name or '*' in stock_name:
continue
# 检查是否已经在结果中
if any(r['symbol'] == stock_code for r in results):
continue
# 搜索匹配
if (keyword in stock_code or
keyword.lower() in stock_name.lower() or
stock_code.startswith(keyword)):
results.append({
'symbol': stock_code,
'name': stock_name,
'price': float(row['最新价']) if pd.notna(row['最新价']) else 0.0,
'change_pct': float(row['涨跌幅']) if pd.notna(row['涨跌幅']) else 0.0,
'source': '全市场搜索'
})
# 限制结果数量
if len(results) >= 30:
break
except Exception as e:
pass
# 排序:优先显示代码匹配的结果
def sort_key(item):
if item['symbol'].startswith(keyword):
return (0, item['symbol']) # 代码开头匹配优先级最高
elif keyword in item['symbol']:
return (1, item['symbol']) # 代码包含匹配次之
else:
return (2, item['symbol']) # 名称匹配最后
results.sort(key=sort_key)
# 限制返回结果数量
return results[:20]
except Exception as e:
return []
def get_stock_by_sector(self, sector=None):
"""根据行业获取股票列表"""
try:
popular_stocks = self.get_popular_stocks()
if sector:
return [stock for stock in popular_stocks if stock.get('sector', '') == sector]
else:
# 按行业分组
sectors = {}
for stock in popular_stocks:
sector_name = stock.get('sector', '其他')
if sector_name not in sectors:
sectors[sector_name] = []
sectors[sector_name].append(stock)
return sectors
except Exception as e:
return {} if sector is None else []
def get_all_sectors(self):
"""获取所有行业分类"""
try:
popular_stocks = self.get_popular_stocks()
sectors = set()
for stock in popular_stocks:
sector = stock.get('sector', '其他')
sectors.add(sector)
return sorted(list(sectors))
except Exception as e:
return []
def is_trading_day(self, date):
"""判断是否为交易日(排除周末和节假日)"""
try:
# 将日期转换为datetime对象
if isinstance(date, str):
date = datetime.strptime(date.split()[0], '%Y-%m-%d')
elif isinstance(date, pd.Timestamp):
date = date.to_pydatetime()
# 周末不是交易日
if date.weekday() >= 5: # 5=周六, 6=周日
return False
# 这里可以进一步添加节假日判断
# 目前暂时只过滤周末
return True
except Exception as e:
return True # 默认返回True,避免过度过滤
def is_trading_time(self, dt):
"""判断是否为交易时间"""
try:
if isinstance(dt, str):
dt = pd.to_datetime(dt)
time_str = dt.strftime('%H:%M')
# 上午交易时间:09:30-11:30
morning_start = self.trading_hours['morning']['start']
morning_end = self.trading_hours['morning']['end']
# 下午交易时间:13:00-15:00
afternoon_start = self.trading_hours['afternoon']['start']
afternoon_end = self.trading_hours['afternoon']['end']
return ((morning_start <= time_str <= morning_end) or
(afternoon_start <= time_str <= afternoon_end))
except Exception as e:
return True # 默认返回True,避免过度过滤
def adjust_timestamp_for_trading_hours(self, df, timeframe):
"""根据A股交易时间调整时间戳"""
try:
if df is None or len(df) == 0:
return df
# 确保date列是datetime类型
if 'date' in df.columns:
df['date'] = pd.to_datetime(df['date'])
# 对于日线数据,设置为收盘时间(15:00)
if timeframe == '1d':
df['date'] = df['date'].dt.normalize() + pd.Timedelta(hours=15)
# 对于分钟级数据,过滤非交易时间的数据
elif timeframe in ['1m', '5m', '15m', '30m', '1h']:
# 过滤交易日
df = df[df['date'].apply(self.is_trading_day)]
# 过滤交易时间(只在有足够数据时进行)
if len(df) > 10: # 避免过度过滤导致数据不足
df = df[df['date'].apply(self.is_trading_time)]
# 重新计算时间戳
if 'date' in df.columns:
# 将时间转换为上海时区
df['date'] = df['date'].dt.tz_localize('Asia/Shanghai', ambiguous='infer', nonexistent='shift_forward')
# 转换为毫秒时间戳
df['timestamp'] = df['date'].astype('int64') // 10**6
return df.reset_index(drop=True)
except Exception as e:
return df
def get_trading_calendar(self, start_date, end_date):
"""获取交易日历(简化版本)"""
try:
# 使用akshare获取交易日历
trading_calendar = ak.tool_trade_date_hist_sina()
# 过滤指定日期范围
start_dt = pd.to_datetime(start_date)
end_dt = pd.to_datetime(end_date)
trading_days = []
for _, row in trading_calendar.iterrows():
trade_date = pd.to_datetime(row['trade_date'])
if start_dt <= trade_date <= end_dt:
trading_days.append(trade_date.strftime('%Y-%m-%d'))
return trading_days
except Exception as e:
# 如果获取失败,生成简单的工作日列表(排除周末)
trading_days = []
current = pd.to_datetime(start_date)
end = pd.to_datetime(end_date)
while current <= end:
if current.weekday() < 5: # 周一到周五
trading_days.append(current.strftime('%Y-%m-%d'))
current += timedelta(days=1)
return trading_days
def fill_trading_gaps(self, df, timeframe):
"""填补A股交易时间间隙,确保图表连续性"""
try:
if df is None or len(df) == 0:
return df
# 对于日线数据,不需要填补间隙,因为本来就是每日一个数据点
if timeframe == '1d':
return df
# 对于分钟级数据,创建完整的交易时间序列
if timeframe in ['1m', '5m', '15m', '30m', '1h']:
# 获取数据的开始和结束时间
start_date = df['date'].min().date()
end_date = df['date'].max().date()
# 创建完整的交易时间序列
complete_times = []
current_date = start_date
# 获取时间间隔(分钟)
freq_map = {'1m': 1, '5m': 5, '15m': 15, '30m': 30, '1h': 60}
freq_minutes = freq_map.get(timeframe, 5)
while current_date <= end_date:
# 只处理交易日
if self.is_trading_day(current_date):
# 上午交易时间 - 使用datetime.time而不是pd.Time
morning_start = pd.Timestamp.combine(current_date, time(9, 30))
morning_end = pd.Timestamp.combine(current_date, time(11, 30))
# 下午交易时间
afternoon_start = pd.Timestamp.combine(current_date, time(13, 0))
afternoon_end = pd.Timestamp.combine(current_date, time(15, 0))
# 生成上午时间序列
current_time = morning_start
while current_time <= morning_end:
complete_times.append(current_time)
current_time += pd.Timedelta(minutes=freq_minutes)
# 生成下午时间序列
current_time = afternoon_start
while current_time <= afternoon_end:
complete_times.append(current_time)
current_time += pd.Timedelta(minutes=freq_minutes)
current_date += timedelta(days=1)
# 创建完整时间序列的DataFrame
if complete_times:
complete_df = pd.DataFrame({'date': complete_times})
complete_df['date'] = complete_df['date'].dt.tz_localize('Asia/Shanghai')
complete_df['timestamp'] = complete_df['date'].astype('int64') // 10**6
# 将原始数据合并到完整时间序列
# 使用时间戳进行合并,避免时区问题
df_merged = pd.merge(complete_df, df, on='timestamp', how='left', suffixes=('', '_orig'))
# 保持原有date列
df_merged['date'] = df_merged['date']
# 对于缺失的OHLCV数据,使用前向填充
price_cols = ['open', 'high', 'low', 'close']
for col in price_cols:
if col in df_merged.columns:
df_merged[col] = df_merged[col].ffill()
# 成交量缺失时设为0
if 'volume' in df_merged.columns:
df_merged['volume'] = df_merged['volume'].fillna(0)
# 删除辅助列
cols_to_drop = [col for col in df_merged.columns if col.endswith('_orig')]
df_merged = df_merged.drop(columns=cols_to_drop)
return df_merged
return df
except Exception as e:
return df
def clean_a_stock_data(self, df, timeframe):
"""清理A股数据,处理异常值和时间问题"""
try:
if df is None or len(df) == 0:
return df
import numpy as np
# 首先删除所有包含NaN的行
df = df.dropna()
# 删除价格异常的数据
price_cols = ['open', 'high', 'low', 'close']
for col in price_cols:
if col in df.columns:
# 删除价格为0、负数、NaN、inf的记录
df = df[df[col] > 0]
df = df[np.isfinite(df[col])]
# 检查OHLC逻辑合理性
if all(col in df.columns for col in price_cols):
# high应该是最高价
df = df[df['high'] >= df['open']]
df = df[df['high'] >= df['close']]
# low应该是最低价
df = df[df['low'] <= df['open']]
df = df[df['low'] <= df['close']]
# high应该大于等于low
df = df[df['high'] >= df['low']]
# 删除成交量异常的数据
if 'volume' in df.columns:
# 删除成交量为负数、NaN、inf的记录
df = df[df['volume'] >= 0]
df = df[np.isfinite(df['volume'])]
# 确保所有数值列都不包含NaN或无限值
numeric_cols = df.select_dtypes(include=[np.number]).columns
for col in numeric_cols:
# 替换NaN、inf、-inf为0(除了价格列,价格列的异常值已经被过滤掉了)
if col not in price_cols:
df[col] = df[col].replace([np.nan, np.inf, -np.inf], 0)
# 确保时间序列连续性(仅对分钟级数据)
if timeframe in ['1m', '5m', '15m', '30m', '1h']:
df = self.fill_trading_gaps(df, timeframe)
# 最后再次检查并清理任何剩余的NaN值
df = df.dropna()
return df.reset_index(drop=True)
except Exception as e:
return df