Add A share to display

This commit is contained in:
Porter
2025-05-25 14:37:47 +08:00
parent b4aef0b728
commit 3f16a5e6ce
16 changed files with 1718 additions and 215 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+78
View File
@@ -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()
+192
View File
@@ -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测试完成!")
+232 -50
View File
@@ -13,11 +13,13 @@ import time
import traceback import traceback
from pytz import timezone from pytz import timezone
import talib.abstract as ta import talib.abstract as ta
import numpy as np
# 添加父目录到系统路径 # 添加父目录到系统路径
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ChanLun import ChanLun from ChanLun import ChanLun
from ChanEnum import Chan_BI_DIR, Chan_SEG_DIR, Chan_KLC_FX, Chan_FX_TYPE 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: class TRADE_POINT_TYPE:
@@ -35,6 +37,9 @@ exchange = ccxt.binance({
'enableRateLimit': True, 'enableRateLimit': True,
}) })
# 初始化A股数据获取器
china_stock = ChinaStockData()
# 时间周期映射 # 时间周期映射
TIMEFRAMES = { TIMEFRAMES = {
'1m': '1分钟', '1m': '1分钟',
@@ -54,8 +59,32 @@ SYMBOLS = [
'ADA/USDT:USDT', 'DOGE/USDT:USDT', 'AVAX/USDT:USDT', 'DOT/USDT:USDT', 'MATIC/USDT:USDT' '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): 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: try:
# 初始化参数 # 初始化参数
since = None since = None
@@ -73,42 +102,73 @@ def get_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=None):
except ValueError: except ValueError:
print(f"无效的结束时间: {end_time}") 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线数据的列表 # 初始化存储所有K线数据的列表
all_ohlcv = [] all_ohlcv = []
# 初始化当前查询的开始时间 # 初始化当前查询的开始时间
current_since = since current_since = since
# 添加请求计数和最大限制
request_count = 0
max_requests = 50 # 最大请求次数,防止无限循环
print(f"开始分批获取加密货币数据: {symbol}, {timeframe}")
# 分页加载数据 # 分页加载数据
while True: while request_count < max_requests:
print(f"获取数据: {symbol}, {timeframe}, limit={limit}, since={current_since}") request_count += 1
# 获取当前页的数据 print(f"批次 {request_count}: 获取数据 since={current_since}, limit={batch_size}")
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since=current_since, limit=limit)
# 如果没有获取到数据,结束循环 try:
if not ohlcv or len(ohlcv) == 0: # 获取当前页的数据
break ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since=current_since, limit=batch_size)
# 将获取到数据添加到总列表中 # 如果没有获取到数据,结束循环
all_ohlcv.extend(ohlcv) if not ohlcv or len(ohlcv) == 0:
print(f"批次 {request_count}: 未获取到数据,结束")
# 获取最后一条数据的时间戳 break
last_timestamp = ohlcv[-1][0]
# 将获取到的数据添加到总列表中
# 如果已达到结束时间,结束循环 all_ohlcv.extend(ohlcv)
if until and last_timestamp >= until: print(f"批次 {request_count}: 获取到 {len(ohlcv)} 条记录")
break
# 如果获取的数据条数小于限制数,说明已经获取完所有数据 # 获取最后一条数据的时间戳
if len(ohlcv) < limit: last_timestamp = ohlcv[-1][0]
break
# 更新下一页的开始时间(加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请求过于频繁 # 防止API请求过于频繁
time.sleep(0.5) # 等待0.5秒 time.sleep(0.3) # 减少到0.3秒提高效率
# 数据为空的情况 # 数据为空的情况
if not all_ohlcv or len(all_ohlcv) == 0: 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') df = df.sort_values('timestamp')
"""
chan = ChanLun() # 限制数据条数的逻辑 - 优先考虑时间范围
klc_list = chan.get_klc_list(df) if start_time and end_time:
klc_index = 0 # 如果指定了明确的时间范围,返回该时间范围内的所有数据
df_copy = df.copy() print(f"用户指定了时间范围,返回完整数据 {len(df)} 条记录")
ret_df = pd.DataFrame(columns=['timestamp', 'open', 'high', 'low', 'close', 'volume', 'date']) if len(df) > 10000: # 防止数据量过大,设置一个合理的上限
for index in range(0, len(df)): print(f"警告:数据量过大({len(df)}条),为保证性能将限制为最新的10000条记录")
if klc_index >= len(klc_list): df = df.tail(10000).reset_index(drop=True)
klc_index = len(klc_list) - 1 elif limit and len(df) > limit:
klc = klc_list[klc_index] # 如果没有指定明确时间范围,使用默认的limit限制
if index == klc.start_klu.index: print(f"未指定明确时间范围,应用默认限制,返回最新的 {limit} 条记录")
ret_df.loc[klc_index] = df_copy.loc[index] df = df.tail(limit).reset_index(drop=True)
klc_index += 1
"""
df = add_indicators(df) df = add_indicators(df)
# 如果过滤后没有数据,返回None # 如果过滤后没有数据,返回None
if len(df) == 0: if len(df) == 0:
print("过滤后无数据") print("过滤后无数据")
return None return None
print(f"获取到总共 {len(df)}数据") print(f"成功获取加密货币数据: {len(df)}记录 (共 {request_count} 个批次)")
return df return df
except Exception as e: except Exception as e:
print(f"获取数据错误: {e}") print(f"获取加密货币数据错误: {e}")
traceback.print_exc() traceback.print_exc()
return None 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): def add_indicators(df):
fast = 8 fast = 8
slow = 16 slow = 16
@@ -178,12 +281,17 @@ def add_indicators(df):
df['ma250'] = df['ma250'].fillna(0) df['ma250'] = df['ma250'].fillna(0)
df['rsi'] = df['rsi'].fillna(0) df['rsi'] = df['rsi'].fillna(0)
df['avg_volume'] = df['volume'].rolling(10).mean() df['avg_volume'] = df['volume'].rolling(10).mean()
# 计算量比 # 计算量比,避免产生Infinity值
df['volume_ratio'] = df['volume'] / df['avg_volume'] df['volume_ratio'] = df['volume'] / df['avg_volume']
# 填充缺失值(前N根K线) # 填充缺失值(前N根K线)
df['volume_ratio'] = df['volume_ratio'].fillna(1.0) df['volume_ratio'] = df['volume_ratio'].fillna(1.0)
df['avg_volume'] = df['avg_volume'].fillna(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 return df
def calculate_macd(df): def calculate_macd(df):
"""计算MACD指标""" """计算MACD指标"""
exp1 = df['close'].ewm(span=12, adjust=False).mean() 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): def convert_direction(direction):
"""将缠论方向枚举转换为整数""" """转换方向枚举为数字"""
if direction == Chan_BI_DIR.UP: if direction == Chan_BI_DIR.UP or direction == Chan_SEG_DIR.UP:
return 1 return 1
elif direction == Chan_BI_DIR.DOWN: elif direction == Chan_BI_DIR.DOWN or direction == Chan_SEG_DIR.DOWN:
return -1
elif direction == Chan_SEG_DIR.UP:
return 1
elif direction == Chan_SEG_DIR.DOWN:
return -1 return -1
else: else:
return 0 return 0
@@ -380,10 +484,34 @@ def is_smaller_or_equal_timeframe(tf1, tf2):
# 返回tf1是否小于等于tf2 # 返回tf1是否小于等于tf2
return tf1_value <= tf2_value 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('/') @app.route('/')
def index(): 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') @app.route('/api/analyze')
def analyze(): def analyze():
@@ -448,7 +576,7 @@ def analyze():
# 添加主周期分析结果到返回数据 # 添加主周期分析结果到返回数据
result.update({ result.update({
'kline_data': df.to_dict('records'), 'kline_data': clean_dataframe_for_json(df).to_dict('records'),
'bi_list': [{ '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(), '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, '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] } for bi in element_analysis['bi_list'] if bi.end_klc]
# 添加小周期K线数据 # 添加小周期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'] = [{ 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(), '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: except Exception as e:
return jsonify({'error': str(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__': if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=8123) app.run(debug=True, host='0.0.0.0', port=8123)
+749
View File
@@ -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
+2 -1
View File
@@ -10,4 +10,5 @@ xgboost>=1.5.0
scikit-learn>=1.0.1 scikit-learn>=1.0.1
ta-lib>=0.4.19 ta-lib>=0.4.19
bootstrap-flask>=2.0.0 bootstrap-flask>=2.0.0
gunicorn>=20.1.0 gunicorn>=20.1.0
akshare>=1.12.0
+465 -164
View File
@@ -250,7 +250,14 @@
<div class="controls"> <div class="controls">
<div class="row g-3 align-items-end"> <div class="row g-3 align-items-end">
<div class="col-md-3"> <div class="col-md-2">
<label for="dataSource" class="form-label">数据源:</label>
<select id="dataSource" class="form-select">
<option value="crypto" selected>加密货币</option>
<option value="a_stock">A股</option>
</select>
</div>
<div class="col-md-3" id="cryptoSymbolContainer">
<label for="symbol" class="form-label">交易对:</label> <label for="symbol" class="form-label">交易对:</label>
<select id="symbol" class="form-select"> <select id="symbol" class="form-select">
{% for symbol in symbols %} {% for symbol in symbols %}
@@ -258,6 +265,14 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-md-3" id="astockSymbolContainer" style="display:none;">
<label for="astockSymbol" class="form-label">A股股票:</label>
<select id="astockSymbol" class="form-select">
{% for stock in a_stock_symbols %}
<option value="{{ stock.symbol }}" {% if stock.symbol == '000001' %}selected{% endif %}>{{ stock.symbol }} - {{ stock.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2"> <div class="col-md-2">
<label for="timeframe" class="form-label">时间周期:</label> <label for="timeframe" class="form-label">时间周期:</label>
<select id="timeframe" class="form-select"> <select id="timeframe" class="form-select">
@@ -277,11 +292,11 @@
<option value="Asia/Tokyo">Asia/Tokyo (UTC+9)</option> <option value="Asia/Tokyo">Asia/Tokyo (UTC+9)</option>
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="col-md-1">
<label for="start_time" class="form-label">开始时间:</label> <label for="start_time" class="form-label">开始时间:</label>
<input type="datetime-local" id="start_time" class="form-control"> <input type="datetime-local" id="start_time" class="form-control">
</div> </div>
<div class="col-md-2"> <div class="col-md-1">
<label for="end_time" class="form-label">结束时间:</label> <label for="end_time" class="form-label">结束时间:</label>
<input type="datetime-local" id="end_time" class="form-control"> <input type="datetime-local" id="end_time" class="form-control">
</div> </div>
@@ -1094,17 +1109,25 @@
$('#refreshLoadingSpinner').show(); $('#refreshLoadingSpinner').show();
// 获取参数 // 获取参数
const symbol = $('#symbol').val() || 'BTC/USDT:USDT'; const dataSource = $('#dataSource').val() || 'crypto';
let symbol;
if (dataSource === 'crypto') {
symbol = $('#symbol').val() || 'BTC/USDT:USDT';
} else {
symbol = $('#astockSymbol').val() || '000001';
}
const timeframe = $('#timeframe').val() || '5m'; const timeframe = $('#timeframe').val() || '5m';
const timezone = $('#timezone').val() || 'Asia/Shanghai'; const timezone = $('#timezone').val() || 'Asia/Shanghai';
const elementTimeframe = $('#elementTimeframe').val() || '1m'; const elementTimeframe = $('#elementTimeframe').val() || '1m';
// 确保时区参数有效 // 确保时区参数有效
console.log('更新图表使用时区:', timezone); console.log('更新图表使用时区:', timezone);
console.log('数据源:', dataSource, '交易对/股票:', symbol);
// 如果symbol为空,不发送请求 // 如果symbol为空,不发送请求
if (!symbol) { if (!symbol) {
console.error('交易对不能为空'); console.error('交易对/股票代码不能为空');
$('#refreshLoadingSpinner').hide(); $('#refreshLoadingSpinner').hide();
return; return;
} }
@@ -1162,7 +1185,75 @@
// 初始化图表 // 初始化图表
function initTradingView(symbol, timeframe) { function initTradingView(symbol, timeframe) {
try { try {
console.log('初始化图表:', symbol, timeframe); console.log('初始化TradingView图表:', symbol, timeframe);
// 获取当前交易对的配置
const symbolConfig = getSymbolConfig(symbol);
console.log('交易对配置:', symbolConfig);
// 检查数据是否存在
if (!currentData || !currentData.kline_data) {
console.error('数据加载失败或不存在');
return;
}
// 检查是否使用小周期K线数据
const useElementPeriod = $('#useElementPeriod').is(':checked') &&
currentData.element_kline_data &&
Array.isArray(currentData.element_kline_data);
// 输出K线周期选择状态
console.log('K线周期选择:', useElementPeriod ? '小周期' : '主周期');
console.log('当前选择时区:', $('#timezone').val());
console.log('交易对类型:', symbolConfig.type);
let candles = [];
if (useElementPeriod) {
// 使用小周期K线数据
candles = currentData.element_kline_data.map((kline) => {
// 使用原始日期字符串创建Date对象
const date = new Date(kline.date);
// 获取时间戳(秒)- 不手动调整时区
const timestamp = date.getTime() / 1000;
return {
time: timestamp,
open: parseFloat(kline.open),
high: parseFloat(kline.high),
low: parseFloat(kline.low),
close: parseFloat(kline.close),
};
});
} else {
// 使用主周期K线数据 - 检查数据是否存在
if (!currentData.kline_data || !Array.isArray(currentData.kline_data)) {
console.error('主周期K线数据不存在或不是数组:', currentData.kline_data);
return;
}
candles = currentData.kline_data.map((kline) => {
// 使用原始日期字符串创建Date对象
const date = new Date(kline.date);
// 获取时间戳(秒)- 不手动调整时区
const timestamp = date.getTime() / 1000;
return {
time: timestamp,
open: parseFloat(kline.open),
high: parseFloat(kline.high),
low: parseFloat(kline.low),
close: parseFloat(kline.close),
};
});
}
// 根据交易对类型过滤数据(仅用于显示优化)
if (symbolConfig.type === 'a_stock' && timeframe.includes('m')) {
// 对于A股分钟级数据,过滤非交易时间
const originalLength = candles.length;
candles = filterTradingHours(candles, symbolConfig);
console.log(`A股数据过滤: ${originalLength} -> ${candles.length} 条记录`);
}
// 清除图表容器 // 清除图表容器
document.getElementById('tradingview_chart').innerHTML = ''; document.getElementById('tradingview_chart').innerHTML = '';
@@ -1253,96 +1344,129 @@
let syncInProgress = false; let syncInProgress = false;
// 创建统一的图表选项 // 创建统一的图表选项
const createChartOptions = (showTimeScale = true) => ({ const createChartOptions = (showTimeScale = true) => {
width: mainChartContainer.clientWidth, const baseOptions = {
height: mainChartContainer.clientHeight, width: mainChartContainer.clientWidth,
layout: { height: mainChartContainer.clientHeight,
background: { color: '#ffffff' }, layout: {
textColor: '#333', background: { color: '#ffffff' },
}, textColor: '#333',
grid: {
vertLines: { color: '#f0f0f0' },
horzLines: { color: '#f0f0f0' },
},
crosshair: {
mode: LightweightCharts.CrosshairMode.Normal,
// 添加十字线工具提示本地化配置
horzLine: {
labelVisible: true,
}, },
vertLine: { grid: {
labelVisible: true, vertLines: { color: '#f0f0f0' },
// 自定义时间格式化 horzLines: { color: '#f0f0f0' },
labelFormatter: (time) => { },
crosshair: {
mode: LightweightCharts.CrosshairMode.Normal,
// 添加十字线工具提示本地化配置
horzLine: {
labelVisible: true,
},
vertLine: {
labelVisible: true,
// 自定义时间格式化
labelFormatter: (time) => {
const selectedTimezone = $('#timezone').val();
try {
const date = new Date(time * 1000);
if (symbolConfig.type === 'a_stock') {
// A股使用中国时区格式
return date.toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} else {
return date.toLocaleString('zh-CN', {
timeZone: selectedTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
} catch (e) {
console.error('十字线时间格式化错误:', e);
return new Date(time * 1000).toLocaleString();
}
},
},
},
rightPriceScale: {
borderColor: '#ddd',
},
// 添加本地化选项,确保所有时间显示都使用选定的时区
localization: {
timeFormatter: (time) => {
const selectedTimezone = $('#timezone').val(); const selectedTimezone = $('#timezone').val();
try { try {
return new Date(time * 1000).toLocaleString('zh-CN', { const date = new Date(time * 1000);
if (symbolConfig.type === 'a_stock') {
// A股使用中国时区格式
return date.toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} else {
return date.toLocaleString('zh-CN', {
timeZone: selectedTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
} catch (e) {
console.error('全局时间格式化错误:', e);
return new Date(time * 1000).toLocaleString();
}
}
},
timeScale: {
timeVisible: true,
secondsVisible: false,
visible: showTimeScale,
borderColor: '#ddd',
barSpacing: symbolConfig.type === 'a_stock' ? 6 : 10,
tickMarkFormatter: (time) => {
const selectedTimezone = symbolConfig.type === 'a_stock' ? 'Asia/Shanghai' : $('#timezone').val();
try {
// 使用完整的配置确保时区正确应用
const date = new Date(time * 1000);
console.log('格式化时间:', time, '转换为:', date.toISOString(), '时区:', selectedTimezone);
return date.toLocaleString('zh-CN', {
timeZone: selectedTimezone, timeZone: selectedTimezone,
year: 'numeric', month: 'numeric',
month: '2-digit', day: 'numeric',
day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit'
}); });
} catch (e) { } catch (e) {
console.error('十字线时间格式化错误:', e); console.error('时间格式化错误:', e);
// 如果时区格式化失败,返回简单格式
return new Date(time * 1000).toLocaleString(); return new Date(time * 1000).toLocaleString();
} }
}, },
}, },
}, };
rightPriceScale: {
borderColor: '#ddd', // 根据交易对类型调整配置
}, return adjustChartForSymbolType(baseOptions, symbolConfig);
// 添加本地化选项,确保所有时间显示都使用选定的时区 };
localization: {
timeFormatter: (time) => {
const selectedTimezone = $('#timezone').val();
try {
return new Date(time * 1000).toLocaleString('zh-CN', {
timeZone: selectedTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} catch (e) {
console.error('全局时间格式化错误:', e);
return new Date(time * 1000).toLocaleString();
}
}
},
timeScale: {
timeVisible: true,
secondsVisible: false,
visible: showTimeScale,
borderColor: '#ddd',
barSpacing: 10,
tickMarkFormatter: (time) => {
const selectedTimezone = $('#timezone').val();
try {
// 使用完整的配置确保时区正确应用
const date = new Date(time * 1000);
console.log('格式化时间:', time, '转换为:', date.toISOString(), '时区:', selectedTimezone);
return date.toLocaleString('zh-CN', {
timeZone: selectedTimezone,
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch (e) {
console.error('时间格式化错误:', e);
// 如果时区格式化失败,返回简单格式
return new Date(time * 1000).toLocaleString();
}
},
},
});
// 创建主图表 // 创建主图表
const mainChart = LightweightCharts.createChart(mainChartContainer, createChartOptions(true)); const mainChart = LightweightCharts.createChart(mainChartContainer, createChartOptions(true));
@@ -1356,50 +1480,6 @@
macdChart = LightweightCharts.createChart(macdChartContainer, createChartOptions(false)); macdChart = LightweightCharts.createChart(macdChartContainer, createChartOptions(false));
} }
// 转换K线数据 - 根据选中的周期使用主周期或小周期数据
let candles;
const useElementPeriod = $('#elementPeriodKline').is(':checked') &&
currentData.element_timeframe &&
currentData.element_kline_data;
// 输出K线周期选择状态
console.log('K线周期选择:', useElementPeriod ? '小周期' : '主周期');
console.log('当前选择时区:', $('#timezone').val());
if (useElementPeriod) {
// 使用小周期K线数据
candles = currentData.element_kline_data.map((kline) => {
// 使用原始日期字符串创建Date对象
const date = new Date(kline.date);
// 获取时间戳(秒)- 不手动调整时区
const timestamp = date.getTime() / 1000;
return {
time: timestamp,
open: parseFloat(kline.open),
high: parseFloat(kline.high),
low: parseFloat(kline.low),
close: parseFloat(kline.close),
};
});
} else {
// 使用主周期K线数据
candles = currentData.kline_data.map((kline) => {
// 使用原始日期字符串创建Date对象
const date = new Date(kline.date);
// 获取时间戳(秒)- 不手动调整时区
const timestamp = date.getTime() / 1000;
return {
time: timestamp,
open: parseFloat(kline.open),
high: parseFloat(kline.high),
low: parseFloat(kline.low),
close: parseFloat(kline.close),
};
});
}
// 创建蜡烛图系列并设置数据 // 创建蜡烛图系列并设置数据
if (showOriginalKline) { if (showOriginalKline) {
const candleSeries = mainChart.addCandlestickSeries({ const candleSeries = mainChart.addCandlestickSeries({
@@ -1432,17 +1512,9 @@
} }
// 转换成交量数据 - 始终使用主K线周期数据 // 转换成交量数据 - 始终使用主K线周期数据
const volumes = useElementPeriod ? let volumes = [];
currentData.element_kline_data.map(kline => { if (useElementPeriod && currentData.element_kline_data && Array.isArray(currentData.element_kline_data)) {
// 创建日期对象并获取时间戳,不手动调整时区 volumes = currentData.element_kline_data.map(kline => {
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
return {
time: timestamp,
value: parseFloat(kline.volume),
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(220, 53, 69, 0.5)' : 'rgba(40, 167, 69, 0.5)',
};
}) :
currentData.kline_data.map(kline => {
// 创建日期对象并获取时间戳,不手动调整时区 // 创建日期对象并获取时间戳,不手动调整时区
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
return { return {
@@ -1451,6 +1523,17 @@
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(220, 53, 69, 0.5)' : 'rgba(40, 167, 69, 0.5)', color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(220, 53, 69, 0.5)' : 'rgba(40, 167, 69, 0.5)',
}; };
}); });
} else if (currentData.kline_data && Array.isArray(currentData.kline_data)) {
volumes = currentData.kline_data.map(kline => {
// 创建日期对象并获取时间戳,不手动调整时区
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
return {
time: timestamp,
value: parseFloat(kline.volume),
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(220, 53, 69, 0.5)' : 'rgba(40, 167, 69, 0.5)',
};
});
}
// 添加成交量图表 // 添加成交量图表
const volumeSeries = volumeChart.addHistogramSeries({ const volumeSeries = volumeChart.addHistogramSeries({
@@ -1464,7 +1547,7 @@
tvWidget.series.volumeSeries = volumeSeries; tvWidget.series.volumeSeries = volumeSeries;
// 添加MACD图表 - 始终使用主K线周期的MACD数据 // 添加MACD图表 - 始终使用主K线周期的MACD数据
if (showMacd && currentData.macd) { if (showMacd && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) {
// 创建MACD线 // 创建MACD线
const macdLineSeries = macdChart.addLineSeries({ const macdLineSeries = macdChart.addLineSeries({
color: '#2962FF', color: '#2962FF',
@@ -2945,6 +3028,12 @@
try { try {
console.log('增量更新图表数据'); console.log('增量更新图表数据');
// 检查 currentData 是否存在
if (!currentData) {
console.error('currentData为空,无法更新图表');
return;
}
// 保存当前的可视范围 // 保存当前的可视范围
if (tvWidget.mainChart) { if (tvWidget.mainChart) {
tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange(); tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange();
@@ -2957,10 +3046,11 @@
// 检查是否使用小周期数据 // 检查是否使用小周期数据
const useElementPeriod = $('#elementPeriodKline').is(':checked') && const useElementPeriod = $('#elementPeriodKline').is(':checked') &&
currentData.element_timeframe && currentData.element_timeframe &&
currentData.element_kline_data; currentData.element_kline_data &&
Array.isArray(currentData.element_kline_data);
// 转换K线数据 // 转换K线数据
let candles; let candles = [];
if (useElementPeriod) { if (useElementPeriod) {
console.log('使用小周期K线数据'); console.log('使用小周期K线数据');
candles = currentData.element_kline_data.map((kline) => { candles = currentData.element_kline_data.map((kline) => {
@@ -2974,7 +3064,7 @@
close: parseFloat(kline.close), close: parseFloat(kline.close),
}; };
}); });
} else { } else if (currentData.kline_data && Array.isArray(currentData.kline_data)) {
console.log('使用主周期K线数据'); console.log('使用主周期K线数据');
candles = currentData.kline_data.map((kline) => { candles = currentData.kline_data.map((kline) => {
const date = new Date(kline.date); const date = new Date(kline.date);
@@ -3003,16 +3093,9 @@
} }
// 更新成交量数据 // 更新成交量数据
const volumes = useElementPeriod ? let volumes = [];
currentData.element_kline_data.map(kline => { if (useElementPeriod && currentData.element_kline_data && Array.isArray(currentData.element_kline_data)) {
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); volumes = currentData.element_kline_data.map(kline => {
return {
time: timestamp,
value: parseFloat(kline.volume),
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(220, 53, 69, 0.5)' : 'rgba(40, 167, 69, 0.5)',
};
}) :
currentData.kline_data.map(kline => {
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
return { return {
time: timestamp, time: timestamp,
@@ -3020,13 +3103,23 @@
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(220, 53, 69, 0.5)' : 'rgba(40, 167, 69, 0.5)', color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(220, 53, 69, 0.5)' : 'rgba(40, 167, 69, 0.5)',
}; };
}); });
} else if (currentData.kline_data && Array.isArray(currentData.kline_data)) {
volumes = currentData.kline_data.map(kline => {
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
return {
time: timestamp,
value: parseFloat(kline.volume),
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(220, 53, 69, 0.5)' : 'rgba(40, 167, 69, 0.5)',
};
});
}
if (tvWidget.series.volumeSeries) { if (tvWidget.series.volumeSeries) {
tvWidget.series.volumeSeries.setData(volumes); tvWidget.series.volumeSeries.setData(volumes);
} }
// 更新MACD数据 // 更新MACD数据
if ($('#showMacd').is(':checked') && currentData.macd && tvWidget.series.macdLineSeries) { if ($('#showMacd').is(':checked') && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data) && tvWidget.series.macdLineSeries) {
// 提取MACD数据 // 提取MACD数据
const macdData = []; const macdData = [];
const signalData = []; const signalData = [];
@@ -3521,8 +3614,11 @@
tables.kline.clear().destroy(); tables.kline.clear().destroy();
} }
// 检查数据是否存在且为数组
const klineData = (data.kline_data && Array.isArray(data.kline_data)) ? data.kline_data : [];
tables.kline = $('#klineTable').DataTable({ tables.kline = $('#klineTable').DataTable({
data: data.kline_data, data: klineData,
order: [[0, 'desc']], order: [[0, 'desc']],
pageLength: 25, pageLength: 25,
columns: [ columns: [
@@ -3659,15 +3755,18 @@
tables.macd.clear().destroy(); tables.macd.clear().destroy();
} }
const macdData = data.kline_data.map((item, index) => { let macdData = [];
return { if (data.kline_data && Array.isArray(data.kline_data) && data.macd && data.macd.macd) {
time: item.date, macdData = data.kline_data.map((item, index) => {
close: item.close, return {
macd: data.macd.macd[index], time: item.date,
signal: data.macd.signal[index], close: item.close,
histogram: data.macd.histogram[index] macd: data.macd.macd[index],
}; signal: data.macd.signal[index],
}); histogram: data.macd.histogram[index]
};
});
}
tables.macd = $('#macdTable').DataTable({ tables.macd = $('#macdTable').DataTable({
data: macdData, data: macdData,
@@ -3786,8 +3885,36 @@
console.log('从本地存储恢复时区设置:', savedTimezone); console.log('从本地存储恢复时区设置:', savedTimezone);
} }
// 初始化数据源切换
$('#dataSource').on('change', function() {
const dataSource = $(this).val();
if (dataSource === 'crypto') {
$('#cryptoSymbolContainer').show();
$('#astockSymbolContainer').hide();
// 停止A股状态更新器
if (window.astockStatusInterval) {
clearInterval(window.astockStatusInterval);
window.astockStatusInterval = null;
}
} else if (dataSource === 'a_stock') {
$('#cryptoSymbolContainer').hide();
$('#astockSymbolContainer').show();
// 加载A股数据时,如果还没有加载股票列表,可以在这里触发加载
loadAStockSymbols();
// 启动A股交易时间状态更新器
startAStockStatusUpdater();
}
});
// 检查初始数据源设置
const initialDataSource = $('#dataSource').val();
if (initialDataSource === 'a_stock') {
startAStockStatusUpdater();
}
// 初始化交易对下拉菜单 // 初始化交易对下拉菜单
$('#symbol').val('BTC/USDT:USDT'); $('#symbol').val('BTC/USDT:USDT');
$('#astockSymbol').val('000001');
$('#timeframe').val('5m'); $('#timeframe').val('5m');
$('#elementTimeframe').val('1m'); $('#elementTimeframe').val('1m');
@@ -4802,6 +4929,180 @@
}); });
} }
} }
// 获取A股股票列表
function loadAStockSymbols() {
$.get('/api/popular_a_stocks', function(data) {
if (Array.isArray(data)) {
const $select = $('#astockSymbol');
const currentSymbol = $select.val(); // 保存当前选中的值
$select.empty();
data.forEach(function(stock) {
$select.append($('<option>', {
value: stock.symbol,
text: stock.symbol + ' - ' + stock.name
}));
});
// 如果有保存的选中值,恢复它
if (currentSymbol && data.some(stock => stock.symbol === currentSymbol)) {
$select.val(currentSymbol);
} else {
// 设置默认值为平安银行
$select.val('000001');
}
}
}).fail(function() {
console.error('加载A股股票列表失败');
});
}
// 检测交易对类型并返回相应的配置
function getSymbolConfig(symbol) {
const isAStock = symbol && symbol.length === 6 && /^\d+$/.test(symbol);
if (isAStock) {
return {
type: 'a_stock',
displayName: symbol,
tradingSessions: [
// A股交易时间配置
{ start: '09:30', end: '11:30' }, // 上午
{ start: '13:00', end: '15:00' } // 下午
],
timezone: 'Asia/Shanghai',
// A股的交易日配置(周一到周五,除节假日)
tradingDays: [1, 2, 3, 4, 5] // 1=周一, 7=周日
};
} else {
return {
type: 'crypto',
displayName: symbol,
tradingSessions: [
{ start: '00:00', end: '23:59' } // 24小时交易
],
timezone: 'UTC',
tradingDays: [1, 2, 3, 4, 5, 6, 7] // 7天交易
};
}
}
// 根据交易对类型调整图表配置
function adjustChartForSymbolType(chartOptions, symbolConfig) {
if (symbolConfig.type === 'a_stock') {
// A股特殊配置
chartOptions.timeScale = {
...chartOptions.timeScale,
// 禁用非交易时间的显示
borderVisible: true,
borderColor: '#ddd',
// 自定义时间格式化,只显示交易时间
timeVisible: true,
// 添加A股特定的时间范围限制
rightOffset: 12,
barSpacing: 6,
minBarSpacing: 3,
};
// 添加A股交易时间提示
chartOptions.layout = {
...chartOptions.layout,
fontSize: 12,
fontFamily: 'Arial, sans-serif'
};
}
return chartOptions;
}
// 过滤非交易时间的数据(仅用于显示优化)
function filterTradingHours(data, symbolConfig) {
if (symbolConfig.type !== 'a_stock') {
return data; // 非A股数据不需要过滤
}
return data.filter(item => {
const date = new Date(item.time * 1000);
const hour = date.getHours();
const minute = date.getMinutes();
const timeStr = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
// 检查是否在交易时间内
return symbolConfig.tradingSessions.some(session => {
return timeStr >= session.start && timeStr <= session.end;
});
});
}
// 更新A股交易时间状态
function updateAStockTradingStatus() {
const now = new Date();
const chinaTime = new Date(now.toLocaleString("en-US", {timeZone: "Asia/Shanghai"}));
const hour = chinaTime.getHours();
const minute = chinaTime.getMinutes();
const dayOfWeek = chinaTime.getDay(); // 0=周日, 1=周一, ..., 6=周六
const statusElement = document.getElementById('tradingTimeStatus');
if (!statusElement) return;
// 检查是否为交易日(周一到周五)
const isTradingDay = dayOfWeek >= 1 && dayOfWeek <= 5;
if (!isTradingDay) {
statusElement.className = 'badge bg-secondary';
statusElement.textContent = '非交易日';
return;
}
// 检查是否在交易时间内
const currentTime = hour * 60 + minute; // 转换为分钟
const morningStart = 9 * 60 + 30; // 09:30
const morningEnd = 11 * 60 + 30; // 11:30
const afternoonStart = 13 * 60; // 13:00
const afternoonEnd = 15 * 60; // 15:00
let status = '';
let className = '';
if (currentTime >= morningStart && currentTime <= morningEnd) {
status = '上午交易中';
className = 'badge bg-success';
} else if (currentTime >= afternoonStart && currentTime <= afternoonEnd) {
status = '下午交易中';
className = 'badge bg-success';
} else if (currentTime > morningEnd && currentTime < afternoonStart) {
status = '午间休市';
className = 'badge bg-warning';
} else if (currentTime < morningStart) {
status = '开盘前';
className = 'badge bg-info';
} else if (currentTime > afternoonEnd) {
status = '收盘后';
className = 'badge bg-dark';
} else {
status = '非交易时间';
className = 'badge bg-secondary';
}
statusElement.className = className;
statusElement.textContent = status;
}
// 启动A股交易时间状态更新
function startAStockStatusUpdater() {
// 如果已经有定时器在运行,先清除
if (window.astockStatusInterval) {
clearInterval(window.astockStatusInterval);
}
// 立即更新一次
updateAStockTradingStatus();
// 每30秒更新一次
window.astockStatusInterval = setInterval(updateAStockTradingStatus, 30000);
console.log('A股交易时间状态更新器已启动');
}
</script> </script>
</body> </body>
</html> </html>