diff --git a/__pycache__/ChanBI.cpython-312.pyc b/__pycache__/ChanBI.cpython-312.pyc index 9b13e87..12fc32a 100644 Binary files a/__pycache__/ChanBI.cpython-312.pyc and b/__pycache__/ChanBI.cpython-312.pyc differ diff --git a/__pycache__/ChanBSP.cpython-312.pyc b/__pycache__/ChanBSP.cpython-312.pyc index cc05b4c..b0781ec 100644 Binary files a/__pycache__/ChanBSP.cpython-312.pyc and b/__pycache__/ChanBSP.cpython-312.pyc differ diff --git a/__pycache__/ChanCTime.cpython-312.pyc b/__pycache__/ChanCTime.cpython-312.pyc index c08bfc5..d700437 100644 Binary files a/__pycache__/ChanCTime.cpython-312.pyc and b/__pycache__/ChanCTime.cpython-312.pyc differ diff --git a/__pycache__/ChanEnum.cpython-312.pyc b/__pycache__/ChanEnum.cpython-312.pyc index 60e4aad..c4a9334 100644 Binary files a/__pycache__/ChanEnum.cpython-312.pyc and b/__pycache__/ChanEnum.cpython-312.pyc differ diff --git a/__pycache__/ChanKLC.cpython-312.pyc b/__pycache__/ChanKLC.cpython-312.pyc index 34916fd..c83bde4 100644 Binary files a/__pycache__/ChanKLC.cpython-312.pyc and b/__pycache__/ChanKLC.cpython-312.pyc differ diff --git a/__pycache__/ChanKLU.cpython-312.pyc b/__pycache__/ChanKLU.cpython-312.pyc index 9c66c5f..c67e272 100644 Binary files a/__pycache__/ChanKLU.cpython-312.pyc and b/__pycache__/ChanKLU.cpython-312.pyc differ diff --git a/__pycache__/ChanLun.cpython-312.pyc b/__pycache__/ChanLun.cpython-312.pyc index e5b56c4..82f8818 100644 Binary files a/__pycache__/ChanLun.cpython-312.pyc and b/__pycache__/ChanLun.cpython-312.pyc differ diff --git a/__pycache__/ChanSBI.cpython-312.pyc b/__pycache__/ChanSBI.cpython-312.pyc index 39238c4..18c9fb8 100644 Binary files a/__pycache__/ChanSBI.cpython-312.pyc and b/__pycache__/ChanSBI.cpython-312.pyc differ diff --git a/__pycache__/ChanSEG.cpython-312.pyc b/__pycache__/ChanSEG.cpython-312.pyc index 309ff07..072139b 100644 Binary files a/__pycache__/ChanSEG.cpython-312.pyc and b/__pycache__/ChanSEG.cpython-312.pyc differ diff --git a/__pycache__/ChanZS.cpython-312.pyc b/__pycache__/ChanZS.cpython-312.pyc index db18ccb..0d7b9ed 100644 Binary files a/__pycache__/ChanZS.cpython-312.pyc and b/__pycache__/ChanZS.cpython-312.pyc differ diff --git a/test_a_stock.py b/test_a_stock.py new file mode 100644 index 0000000..bf01ef1 --- /dev/null +++ b/test_a_stock.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +A股数据获取测试脚本 +""" + +import sys +import os +sys.path.append(os.path.join(os.path.dirname(__file__), 'web')) + +from web.cn_stock_data import ChinaStockData +import pandas as pd + +def test_a_stock_data(): + """测试A股数据获取功能""" + print("开始测试A股数据获取功能...") + + # 初始化A股数据获取器 + china_stock = ChinaStockData() + + # 测试获取热门股票列表 + print("\n1. 测试获取热门股票列表:") + popular_stocks = china_stock.get_popular_stocks() + print(f"热门股票数量: {len(popular_stocks)}") + for i, stock in enumerate(popular_stocks[:5]): + print(f" {i+1}. {stock['symbol']} - {stock['name']}") + + # 测试获取股票K线数据 + print("\n2. 测试获取股票K线数据:") + test_symbols = ['000001', '600519', '000858'] # 平安银行、贵州茅台、五粮液 + + for symbol in test_symbols: + print(f"\n测试股票: {symbol}") + + # 测试日线数据 + print(" 获取日线数据...") + try: + df_daily = china_stock.get_kl_data(symbol, '1d', limit=100) + if df_daily is not None: + print(f" 成功获取 {len(df_daily)} 条日线数据") + print(f" 时间范围: {df_daily['date'].min()} 到 {df_daily['date'].max()}") + print(f" 最新价格: {df_daily['close'].iloc[-1]:.2f}") + else: + print(" 获取日线数据失败") + except Exception as e: + print(f" 获取日线数据出错: {e}") + + # 测试分钟数据 + print(" 获取5分钟数据...") + try: + df_5m = china_stock.get_kl_data(symbol, '5m', limit=50) + if df_5m is not None: + print(f" 成功获取 {len(df_5m)} 条5分钟数据") + print(f" 时间范围: {df_5m['date'].min()} 到 {df_5m['date'].max()}") + else: + print(" 获取5分钟数据失败") + except Exception as e: + print(f" 获取5分钟数据出错: {e}") + + # 测试获取股票列表 + print("\n3. 测试获取股票列表:") + try: + stock_list = china_stock.get_stock_list() + if stock_list: + print(f"成功获取 {len(stock_list)} 只股票") + print("前5只股票:") + for i, stock in enumerate(stock_list[:5]): + print(f" {i+1}. {stock['symbol']} - {stock['name']} - 价格: {stock['price']} - 涨跌幅: {stock['change_pct']}%") + else: + print("获取股票列表失败") + except Exception as e: + print(f"获取股票列表出错: {e}") + + print("\nA股数据获取测试完成!") + +if __name__ == '__main__': + test_a_stock_data() \ No newline at end of file diff --git a/test_batch_data.py b/test_batch_data.py new file mode 100644 index 0000000..6f344a2 --- /dev/null +++ b/test_batch_data.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +测试分批次数据获取功能 +验证A股和加密货币数据的大时间范围获取 +""" + +import sys +import os +sys.path.append('web') + +from datetime import datetime, timedelta +from cn_stock_data import ChinaStockData +import ccxt + +def test_a_stock_batch_data(): + """测试A股分批次数据获取""" + print("=== 测试A股分批次数据获取 ===") + + china_stock = ChinaStockData() + + # 测试获取更长时间范围的数据 + end_date = datetime.now() + start_date = end_date - timedelta(days=180) # 6个月数据 + + print(f"测试时间范围: {start_date.strftime('%Y-%m-%d')} 到 {end_date.strftime('%Y-%m-%d')}") + + # 测试不同时间周期 + test_cases = [ + ('600519', '1d', '日线数据'), + ('600519', '1h', '1小时数据'), + ('600519', '15m', '15分钟数据'), + ] + + for symbol, timeframe, description in test_cases: + print(f"\n测试 {description}: {symbol} {timeframe}") + + try: + df = china_stock.get_kl_data( + symbol=symbol, + timeframe=timeframe, + start_date=start_date.strftime('%Y-%m-%d'), + end_date=end_date.strftime('%Y-%m-%d'), + limit=5000 + ) + + if df is not None: + print(f"✅ 成功获取 {len(df)} 条记录") + print(f" 时间范围: {df['date'].min()} 到 {df['date'].max()}") + print(f" 数据列: {list(df.columns)}") + else: + print(f"❌ 获取失败") + + except Exception as e: + print(f"❌ 错误: {e}") + +def test_crypto_batch_data(): + """测试加密货币分批次数据获取""" + print("\n=== 测试加密货币分批次数据获取 ===") + + # 初始化交易所 + exchange = ccxt.binance({ + 'enableRateLimit': True, + }) + + # 测试获取更长时间范围的数据 + end_time = datetime.now() + start_time = end_time - timedelta(days=30) # 30天数据 + + print(f"测试时间范围: {start_time} 到 {end_time}") + + # 转换为时间戳 + start_timestamp = int(start_time.timestamp() * 1000) + end_timestamp = int(end_time.timestamp() * 1000) + + # 测试不同时间周期 + test_cases = [ + ('BTC/USDT:USDT', '1d', '日线数据'), + ('BTC/USDT:USDT', '1h', '1小时数据'), + ('BTC/USDT:USDT', '5m', '5分钟数据'), + ] + + for symbol, timeframe, description in test_cases: + print(f"\n测试 {description}: {symbol} {timeframe}") + + try: + # 模拟分批次获取逻辑 + all_ohlcv = [] + current_since = start_timestamp + request_count = 0 + max_requests = 10 + + batch_size = 500 if timeframe in ['1m', '5m'] else 1000 + + while request_count < max_requests and current_since < end_timestamp: + request_count += 1 + print(f" 批次 {request_count}: 获取数据...") + + ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since=current_since, limit=batch_size) + + if not ohlcv or len(ohlcv) == 0: + break + + all_ohlcv.extend(ohlcv) + last_timestamp = ohlcv[-1][0] + + if last_timestamp >= end_timestamp: + break + + if len(ohlcv) < batch_size: + break + + current_since = last_timestamp + 1 + + # 防止请求过频 + import time + time.sleep(0.3) + + if all_ohlcv: + print(f"✅ 成功获取 {len(all_ohlcv)} 条记录 (共 {request_count} 个批次)") + + # 时间范围检查 + first_time = datetime.fromtimestamp(all_ohlcv[0][0] / 1000) + last_time = datetime.fromtimestamp(all_ohlcv[-1][0] / 1000) + print(f" 时间范围: {first_time} 到 {last_time}") + else: + print(f"❌ 获取失败") + + except Exception as e: + print(f"❌ 错误: {e}") + +def test_data_quality(): + """测试数据质量""" + print("\n=== 测试数据质量 ===") + + china_stock = ChinaStockData() + + # 获取一小段数据进行质量检查 + df = china_stock.get_kl_data( + symbol='600519', + timeframe='1d', + limit=100 + ) + + if df is not None: + print(f"数据行数: {len(df)}") + print(f"数据列: {list(df.columns)}") + + # 检查缺失值 + missing_values = df.isnull().sum() + print(f"缺失值统计:") + for col, count in missing_values.items(): + if count > 0: + print(f" {col}: {count}") + + # 检查数据类型 + print(f"数据类型:") + for col, dtype in df.dtypes.items(): + print(f" {col}: {dtype}") + + # 检查时间连续性 + if len(df) > 1: + time_diffs = df['date'].diff().dropna() + print(f"时间间隔统计:") + print(f" 最小间隔: {time_diffs.min()}") + print(f" 最大间隔: {time_diffs.max()}") + print(f" 平均间隔: {time_diffs.mean()}") + + # 检查价格合理性 + price_cols = ['open', 'high', 'low', 'close'] + for col in price_cols: + if col in df.columns: + print(f"{col} 价格范围: {df[col].min():.2f} - {df[col].max():.2f}") + + print("✅ 数据质量检查完成") + else: + print("❌ 无法获取数据进行质量检查") + +if __name__ == '__main__': + print("开始测试分批次数据获取功能...\n") + + # 测试A股数据 + test_a_stock_batch_data() + + # 测试加密货币数据 + test_crypto_batch_data() + + # 测试数据质量 + test_data_quality() + + print("\n测试完成!") \ No newline at end of file diff --git a/web/app.py b/web/app.py index d03c761..eab040e 100644 --- a/web/app.py +++ b/web/app.py @@ -13,11 +13,13 @@ import time import traceback from pytz import timezone import talib.abstract as ta +import numpy as np # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from ChanLun import ChanLun from ChanEnum import Chan_BI_DIR, Chan_SEG_DIR, Chan_KLC_FX, Chan_FX_TYPE +from cn_stock_data import ChinaStockData # 添加买卖点枚举类型 class TRADE_POINT_TYPE: @@ -35,6 +37,9 @@ exchange = ccxt.binance({ 'enableRateLimit': True, }) +# 初始化A股数据获取器 +china_stock = ChinaStockData() + # 时间周期映射 TIMEFRAMES = { '1m': '1分钟', @@ -54,8 +59,32 @@ SYMBOLS = [ 'ADA/USDT:USDT', 'DOGE/USDT:USDT', 'AVAX/USDT:USDT', 'DOT/USDT:USDT', 'MATIC/USDT:USDT' ] +# A股热门股票 +A_STOCK_SYMBOLS = china_stock.get_popular_stocks() + +def detect_symbol_type(symbol): + """检测交易对类型:crypto 或 a_stock""" + if '/' in symbol and 'USDT' in symbol: + return 'crypto' + elif len(symbol) == 6 and symbol.isdigit(): + return 'a_stock' + else: + return 'unknown' + def get_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=None): - """获取K线数据,支持分页加载确保获取指定时间范围内的所有数据""" + """获取K线数据,支持加密货币和A股""" + symbol_type = detect_symbol_type(symbol) + + if symbol_type == 'crypto': + return get_crypto_kl_data(symbol, timeframe, limit, start_time, end_time) + elif symbol_type == 'a_stock': + return get_a_stock_kl_data(symbol, timeframe, limit, start_time, end_time) + else: + print(f"未知的交易对类型: {symbol}") + return None + +def get_crypto_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=None): + """获取加密货币K线数据,支持分页加载确保获取指定时间范围内的所有数据""" try: # 初始化参数 since = None @@ -73,42 +102,73 @@ def get_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=None): except ValueError: print(f"无效的结束时间: {end_time}") + # 根据时间周期调整每次请求的数据量 + batch_size = 1000 # 默认批次大小 + if timeframe in ['1m', '3m', '5m']: + batch_size = 500 # 分钟级数据减少批次大小 + elif timeframe in ['15m', '30m', '1h']: + batch_size = 1000 + else: + batch_size = 1500 # 日线及以上可以获取更多 + # 初始化存储所有K线数据的列表 all_ohlcv = [] # 初始化当前查询的开始时间 current_since = since + # 添加请求计数和最大限制 + request_count = 0 + max_requests = 50 # 最大请求次数,防止无限循环 + + print(f"开始分批获取加密货币数据: {symbol}, {timeframe}") + # 分页加载数据 - while True: - print(f"获取数据: {symbol}, {timeframe}, limit={limit}, since={current_since}") + while request_count < max_requests: + request_count += 1 - # 获取当前页的数据 - ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since=current_since, limit=limit) + print(f"批次 {request_count}: 获取数据 since={current_since}, limit={batch_size}") - # 如果没有获取到数据,结束循环 - if not ohlcv or len(ohlcv) == 0: - break + try: + # 获取当前页的数据 + ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since=current_since, limit=batch_size) - # 将获取到的数据添加到总列表中 - all_ohlcv.extend(ohlcv) - - # 获取最后一条数据的时间戳 - last_timestamp = ohlcv[-1][0] - - # 如果已达到结束时间,结束循环 - if until and last_timestamp >= until: - break + # 如果没有获取到数据,结束循环 + if not ohlcv or len(ohlcv) == 0: + print(f"批次 {request_count}: 未获取到数据,结束") + break + + # 将获取到的数据添加到总列表中 + all_ohlcv.extend(ohlcv) + print(f"批次 {request_count}: 获取到 {len(ohlcv)} 条记录") - # 如果获取的数据条数小于限制数,说明已经获取完所有数据 - if len(ohlcv) < limit: - break + # 获取最后一条数据的时间戳 + last_timestamp = ohlcv[-1][0] - # 更新下一页的开始时间(加1毫秒避免重复) - current_since = last_timestamp + 1 + # 如果已达到结束时间,结束循环 + if until and last_timestamp >= until: + print(f"批次 {request_count}: 已达到结束时间,结束") + break + + # 如果获取的数据条数小于限制数,说明已经获取完所有数据 + if len(ohlcv) < batch_size: + print(f"批次 {request_count}: 数据不足批次大小,已获取完所有数据") + break + + # 更新下一页的开始时间(加1毫秒避免重复) + current_since = last_timestamp + 1 + + except Exception as e: + print(f"批次 {request_count} 获取失败: {e}") + # 如果单个批次失败,继续尝试下一个批次 + if current_since: + # 尝试增加时间跳过可能的问题时间点 + current_since += 60000 # 跳过1分钟 + else: + break # 防止API请求过于频繁 - time.sleep(0.5) # 等待0.5秒 + time.sleep(0.3) # 减少到0.3秒提高效率 # 数据为空的情况 if not all_ohlcv or len(all_ohlcv) == 0: @@ -128,33 +188,76 @@ def get_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=None): # 按时间排序 df = df.sort_values('timestamp') - """ - chan = ChanLun() - klc_list = chan.get_klc_list(df) - klc_index = 0 - df_copy = df.copy() - ret_df = pd.DataFrame(columns=['timestamp', 'open', 'high', 'low', 'close', 'volume', 'date']) - for index in range(0, len(df)): - if klc_index >= len(klc_list): - klc_index = len(klc_list) - 1 - klc = klc_list[klc_index] - if index == klc.start_klu.index: - ret_df.loc[klc_index] = df_copy.loc[index] - klc_index += 1 - """ + + # 限制数据条数的逻辑 - 优先考虑时间范围 + if start_time and end_time: + # 如果指定了明确的时间范围,返回该时间范围内的所有数据 + print(f"用户指定了时间范围,返回完整数据 {len(df)} 条记录") + if len(df) > 10000: # 防止数据量过大,设置一个合理的上限 + print(f"警告:数据量过大({len(df)}条),为保证性能将限制为最新的10000条记录") + df = df.tail(10000).reset_index(drop=True) + elif limit and len(df) > limit: + # 如果没有指定明确时间范围,使用默认的limit限制 + print(f"未指定明确时间范围,应用默认限制,返回最新的 {limit} 条记录") + df = df.tail(limit).reset_index(drop=True) + df = add_indicators(df) + # 如果过滤后没有数据,返回None if len(df) == 0: print("过滤后无数据") return None - print(f"获取到总共 {len(df)} 条数据") + print(f"成功获取加密货币数据: {len(df)} 条记录 (共 {request_count} 个批次)") return df except Exception as e: - print(f"获取数据错误: {e}") + print(f"获取加密货币数据错误: {e}") traceback.print_exc() return None + +def get_a_stock_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=None): + """获取A股K线数据""" + try: + # 处理时间戳参数转换为日期字符串 + start_date = None + end_date = None + + if start_time: + try: + start_timestamp = int(start_time) + start_date = datetime.fromtimestamp(start_timestamp / 1000).strftime('%Y-%m-%d') + except: + start_date = start_time + + if end_time: + try: + end_timestamp = int(end_time) + end_date = datetime.fromtimestamp(end_timestamp / 1000).strftime('%Y-%m-%d') + except: + end_date = end_time + + # 如果用户指定了时间范围,优先获取该范围内的所有数据 + actual_limit = limit + if start_date and end_date: + print(f"用户指定了时间范围 {start_date} 到 {end_date},将获取该范围内的所有数据") + actual_limit = None # 不限制数据条数,获取完整时间范围数据 + + # 调用A股数据获取器 + df = china_stock.get_kl_data(symbol, timeframe, start_date, end_date, actual_limit) + + if df is None: + print(f"未获取到A股数据: {symbol}") + return None + + print(f"获取到A股数据: {len(df)} 条记录") + return df + + except Exception as e: + print(f"获取A股数据错误: {e}") + traceback.print_exc() + return None + def add_indicators(df): fast = 8 slow = 16 @@ -178,12 +281,17 @@ def add_indicators(df): df['ma250'] = df['ma250'].fillna(0) df['rsi'] = df['rsi'].fillna(0) df['avg_volume'] = df['volume'].rolling(10).mean() - # 计算量比 + # 计算量比,避免产生Infinity值 df['volume_ratio'] = df['volume'] / df['avg_volume'] # 填充缺失值(前N根K线) df['volume_ratio'] = df['volume_ratio'].fillna(1.0) df['avg_volume'] = df['avg_volume'].fillna(0) + + # 处理Infinity和-Infinity值 + df['volume_ratio'] = df['volume_ratio'].replace([float('inf'), float('-inf')], 1.0) + return df + def calculate_macd(df): """计算MACD指标""" exp1 = df['close'].ewm(span=12, adjust=False).mean() @@ -287,14 +395,10 @@ def identify_trade_points(bi_list, seg_list, zs_list): # 辅助函数,转换缠论方向枚举为整数 def convert_direction(direction): - """将缠论方向枚举转换为整数""" - if direction == Chan_BI_DIR.UP: + """转换方向枚举为数字""" + if direction == Chan_BI_DIR.UP or direction == Chan_SEG_DIR.UP: return 1 - elif direction == Chan_BI_DIR.DOWN: - return -1 - elif direction == Chan_SEG_DIR.UP: - return 1 - elif direction == Chan_SEG_DIR.DOWN: + elif direction == Chan_BI_DIR.DOWN or direction == Chan_SEG_DIR.DOWN: return -1 else: return 0 @@ -380,10 +484,34 @@ def is_smaller_or_equal_timeframe(tf1, tf2): # 返回tf1是否小于等于tf2 return tf1_value <= tf2_value +def clean_dataframe_for_json(df): + """清理DataFrame中的NaN值,确保JSON序列化正常""" + # 创建副本以避免修改原数据 + df_clean = df.copy() + + # 将NaN、inf、-inf替换为None + df_clean = df_clean.replace([np.nan, np.inf, -np.inf], None) + + # 处理数值列,确保值为有限数字或None + numeric_columns = df_clean.select_dtypes(include=[np.number]).columns + for col in numeric_columns: + # 确保所有数值都是有限的 + df_clean[col] = df_clean[col].apply(lambda x: x if (x is not None and np.isfinite(x)) else None) + + # 处理时间列,确保格式正确 + datetime_columns = df_clean.select_dtypes(include=['datetime64']).columns + for col in datetime_columns: + df_clean[col] = df_clean[col].dt.strftime('%Y-%m-%d %H:%M:%S') + + return df_clean + @app.route('/') def index(): """主页""" - return render_template('index.html', timeframes=TIMEFRAMES, symbols=SYMBOLS) + return render_template('index.html', + timeframes=TIMEFRAMES, + symbols=SYMBOLS, + a_stock_symbols=A_STOCK_SYMBOLS) @app.route('/api/analyze') def analyze(): @@ -448,7 +576,7 @@ def analyze(): # 添加主周期分析结果到返回数据 result.update({ - 'kline_data': df.to_dict('records'), + 'kline_data': clean_dataframe_for_json(df).to_dict('records'), 'bi_list': [{ 'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(), 'end_time': (bi.end_klc.end_time if isinstance(bi.end_klc.end_time, str) else bi.end_klc.end_time.astimezone(client_tz).isoformat()) if bi.end_klc else None, @@ -522,7 +650,7 @@ def analyze(): } for bi in element_analysis['bi_list'] if bi.end_klc] # 添加小周期K线数据 - result['element_kline_data'] = element_df.to_dict('records') + result['element_kline_data'] = clean_dataframe_for_json(element_df).to_dict('records') result['element_seg_list'] = [{ 'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(), @@ -584,5 +712,59 @@ def get_symbols(): except Exception as e: return jsonify({'error': str(e)}) +@app.route('/api/a_stocks') +def get_a_stocks(): + """获取A股股票列表""" + try: + stock_list = china_stock.get_stock_list() + return jsonify(stock_list) + except Exception as e: + return jsonify({'error': str(e)}) + +@app.route('/api/popular_a_stocks') +def get_popular_a_stocks(): + """获取热门A股股票""" + try: + return jsonify(china_stock.get_popular_stocks()) + except Exception as e: + return jsonify({'error': str(e)}) + +@app.route('/api/sectors') +def get_sectors(): + """获取所有行业分类""" + try: + sectors = china_stock.get_all_sectors() + return jsonify(sectors) + except Exception as e: + return jsonify({'error': str(e)}) + +@app.route('/api/stocks_by_sector') +def get_stocks_by_sector(): + """根据行业获取股票""" + try: + sector = request.args.get('sector') + if sector: + stocks = china_stock.get_stock_by_sector(sector) + return jsonify(stocks) + else: + # 返回所有行业的股票分组 + all_sectors = china_stock.get_stock_by_sector() + return jsonify(all_sectors) + except Exception as e: + return jsonify({'error': str(e)}) + +@app.route('/api/search_stock') +def search_stock(): + """搜索股票 - 增强版""" + try: + keyword = request.args.get('keyword', '') + if not keyword: + return jsonify({'error': '搜索关键词不能为空'}) + + results = china_stock.search_stock(keyword) + return jsonify(results) + except Exception as e: + return jsonify({'error': str(e)}) + if __name__ == '__main__': app.run(debug=True, host='0.0.0.0', port=8123) \ No newline at end of file diff --git a/web/cn_stock_data.py b/web/cn_stock_data.py new file mode 100644 index 0000000..0960e03 --- /dev/null +++ b/web/cn_stock_data.py @@ -0,0 +1,749 @@ +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') + +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'} + } + + def get_stock_list(self): + """获取A股股票列表""" + try: + # 获取沪深A股实时行情 + stock_info = ak.stock_zh_a_spot_em() + # 增加到前2000只股票,提供更多选择 + stock_list = [] + for index, row in stock_info.head(2000).iterrows(): + # 过滤掉ST股票和停牌股票 + 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 + }) + + # 按成交金额排序,优先显示活跃股票 + stock_list.sort(key=lambda x: x['amount'], reverse=True) + return stock_list + except Exception as e: + print(f"获取股票列表失败: {e}") + return [] + + def get_popular_stocks(self): + """获取热门A股股票代码列表 - 扩展版本,按行业分类""" + return [ + # 银行股 + {'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') + + def get_kl_data(self, symbol, timeframe='1d', start_date=None, end_date=None, limit=1000): + """ + 获取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('-', '') + + print(f"获取A股数据: {symbol}, 周期: {timeframe}, 开始: {start_date}, 结束: {end_date}") + + # 分批次获取数据以突破单次限制 + 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) + + print(f"批次 {iteration_count}: 获取 {current_start} 到 {current_end} 的数据") + + 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) + print(f"批次 {iteration_count}: 获取到 {len(df_batch)} 条记录") + else: + print(f"批次 {iteration_count}: 未获取到数据") + + except Exception as e: + print(f"批次 {iteration_count} 获取失败: {e}") + # 继续下一个批次 + + # 更新下一批次的开始时间 + current_start = (current_end_dt + timedelta(days=1)).strftime('%Y%m%d') + + # 防止API请求过于频繁 + time_module.sleep(0.5) + + # 合并所有批次的数据 + if not all_data: + print(f"未获取到任何数据: {symbol}") + 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: + # 如果指定了时间范围,优先返回完整的时间范围数据 + print(f"用户指定了时间范围 {start_date} 到 {end_date},返回完整数据 {len(df)} 条记录") + if len(df) > 10000: # 防止数据量过大,设置一个合理的上限 + print(f"警告:数据量过大({len(df)}条),为保证性能将限制为最新的10000条记录") + df = df.tail(10000).reset_index(drop=True) + else: + # 如果没有指定时间范围,使用默认的limit限制 + print(f"未指定明确时间范围,应用默认限制,返回最新的 {limit} 条记录") + df = df.tail(limit).reset_index(drop=True) + elif limit is None and len(df) > 10000: + # 即使没有limit限制,也要防止数据量过大影响性能 + print(f"无limit限制但数据量过大({len(df)}条),为保证性能将限制为最新的10000条记录") + df = df.tail(10000).reset_index(drop=True) + + # 添加技术指标 + df = self.add_indicators(df) + + # 最终数据验证 - 确保没有NaN值 + import numpy as np + + # 检查并处理任何剩余的NaN值 + if df.isnull().any().any(): + print("警告:发现NaN值,正在清理...") + # 对于数值列,用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) + + print(f"成功获取A股数据: {len(df)} 条记录 (共 {len(all_data)} 个批次)") + return df + + except Exception as e: + print(f"获取A股数据失败: {e}") + traceback.print_exc() + 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: + print(f"添加指标失败: {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: + print(f"全市场搜索失败: {e}") + + # 排序:优先显示代码匹配的结果 + 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: + print(f"搜索股票失败: {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: + print(f"获取行业股票失败: {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: + print(f"获取行业分类失败: {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: + print(f"判断交易日失败: {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: + print(f"判断交易时间失败: {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: + print(f"调整A股时间戳失败: {e}") + traceback.print_exc() + 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: + print(f"获取交易日历失败: {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: + print(f"填补交易时间间隙失败: {e}") + traceback.print_exc() + 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: + print(f"清理A股数据失败: {e}") + return df \ No newline at end of file diff --git a/web/requirements.txt b/web/requirements.txt index b73a9ba..cc585d0 100644 --- a/web/requirements.txt +++ b/web/requirements.txt @@ -10,4 +10,5 @@ xgboost>=1.5.0 scikit-learn>=1.0.1 ta-lib>=0.4.19 bootstrap-flask>=2.0.0 -gunicorn>=20.1.0 \ No newline at end of file +gunicorn>=20.1.0 +akshare>=1.12.0 \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html index 834f42a..bf4a85a 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -250,7 +250,14 @@