Add A share to display
This commit is contained in:
+232
-50
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
gunicorn>=20.1.0
|
||||
akshare>=1.12.0
|
||||
+465
-164
@@ -250,7 +250,14 @@
|
||||
|
||||
<div class="controls">
|
||||
<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>
|
||||
<select id="symbol" class="form-select">
|
||||
{% for symbol in symbols %}
|
||||
@@ -258,6 +265,14 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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">
|
||||
<label for="timeframe" class="form-label">时间周期:</label>
|
||||
<select id="timeframe" class="form-select">
|
||||
@@ -277,11 +292,11 @@
|
||||
<option value="Asia/Tokyo">Asia/Tokyo (UTC+9)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="col-md-1">
|
||||
<label for="start_time" class="form-label">开始时间:</label>
|
||||
<input type="datetime-local" id="start_time" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="col-md-1">
|
||||
<label for="end_time" class="form-label">结束时间:</label>
|
||||
<input type="datetime-local" id="end_time" class="form-control">
|
||||
</div>
|
||||
@@ -1094,17 +1109,25 @@
|
||||
$('#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 timezone = $('#timezone').val() || 'Asia/Shanghai';
|
||||
const elementTimeframe = $('#elementTimeframe').val() || '1m';
|
||||
|
||||
// 确保时区参数有效
|
||||
console.log('更新图表使用时区:', timezone);
|
||||
console.log('数据源:', dataSource, '交易对/股票:', symbol);
|
||||
|
||||
// 如果symbol为空,不发送请求
|
||||
if (!symbol) {
|
||||
console.error('交易对不能为空');
|
||||
console.error('交易对/股票代码不能为空');
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
return;
|
||||
}
|
||||
@@ -1162,7 +1185,75 @@
|
||||
// 初始化图表
|
||||
function initTradingView(symbol, timeframe) {
|
||||
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 = '';
|
||||
@@ -1253,96 +1344,129 @@
|
||||
let syncInProgress = false;
|
||||
|
||||
// 创建统一的图表选项
|
||||
const createChartOptions = (showTimeScale = true) => ({
|
||||
width: mainChartContainer.clientWidth,
|
||||
height: mainChartContainer.clientHeight,
|
||||
layout: {
|
||||
background: { color: '#ffffff' },
|
||||
textColor: '#333',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#f0f0f0' },
|
||||
horzLines: { color: '#f0f0f0' },
|
||||
},
|
||||
crosshair: {
|
||||
mode: LightweightCharts.CrosshairMode.Normal,
|
||||
// 添加十字线工具提示本地化配置
|
||||
horzLine: {
|
||||
labelVisible: true,
|
||||
const createChartOptions = (showTimeScale = true) => {
|
||||
const baseOptions = {
|
||||
width: mainChartContainer.clientWidth,
|
||||
height: mainChartContainer.clientHeight,
|
||||
layout: {
|
||||
background: { color: '#ffffff' },
|
||||
textColor: '#333',
|
||||
},
|
||||
vertLine: {
|
||||
labelVisible: true,
|
||||
// 自定义时间格式化
|
||||
labelFormatter: (time) => {
|
||||
grid: {
|
||||
vertLines: { color: '#f0f0f0' },
|
||||
horzLines: { color: '#f0f0f0' },
|
||||
},
|
||||
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();
|
||||
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,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('十字线时间格式化错误:', e);
|
||||
console.error('时间格式化错误:', e);
|
||||
// 如果时区格式化失败,返回简单格式
|
||||
return new Date(time * 1000).toLocaleString();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#ddd',
|
||||
},
|
||||
// 添加本地化选项,确保所有时间显示都使用选定的时区
|
||||
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();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 根据交易对类型调整配置
|
||||
return adjustChartForSymbolType(baseOptions, symbolConfig);
|
||||
};
|
||||
|
||||
// 创建主图表
|
||||
const mainChart = LightweightCharts.createChart(mainChartContainer, createChartOptions(true));
|
||||
@@ -1356,50 +1480,6 @@
|
||||
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) {
|
||||
const candleSeries = mainChart.addCandlestickSeries({
|
||||
@@ -1432,17 +1512,9 @@
|
||||
}
|
||||
|
||||
// 转换成交量数据 - 始终使用主K线周期数据
|
||||
const volumes = useElementPeriod ?
|
||||
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 => {
|
||||
let volumes = [];
|
||||
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 {
|
||||
@@ -1451,6 +1523,17 @@
|
||||
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({
|
||||
@@ -1464,7 +1547,7 @@
|
||||
tvWidget.series.volumeSeries = volumeSeries;
|
||||
|
||||
// 添加MACD图表 - 始终使用主K线周期的MACD数据
|
||||
if (showMacd && currentData.macd) {
|
||||
if (showMacd && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) {
|
||||
// 创建MACD线
|
||||
const macdLineSeries = macdChart.addLineSeries({
|
||||
color: '#2962FF',
|
||||
@@ -2945,6 +3028,12 @@
|
||||
try {
|
||||
console.log('增量更新图表数据');
|
||||
|
||||
// 检查 currentData 是否存在
|
||||
if (!currentData) {
|
||||
console.error('currentData为空,无法更新图表');
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存当前的可视范围
|
||||
if (tvWidget.mainChart) {
|
||||
tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange();
|
||||
@@ -2957,10 +3046,11 @@
|
||||
// 检查是否使用小周期数据
|
||||
const useElementPeriod = $('#elementPeriodKline').is(':checked') &&
|
||||
currentData.element_timeframe &&
|
||||
currentData.element_kline_data;
|
||||
currentData.element_kline_data &&
|
||||
Array.isArray(currentData.element_kline_data);
|
||||
|
||||
// 转换K线数据
|
||||
let candles;
|
||||
let candles = [];
|
||||
if (useElementPeriod) {
|
||||
console.log('使用小周期K线数据');
|
||||
candles = currentData.element_kline_data.map((kline) => {
|
||||
@@ -2974,7 +3064,7 @@
|
||||
close: parseFloat(kline.close),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
} else if (currentData.kline_data && Array.isArray(currentData.kline_data)) {
|
||||
console.log('使用主周期K线数据');
|
||||
candles = currentData.kline_data.map((kline) => {
|
||||
const date = new Date(kline.date);
|
||||
@@ -3003,16 +3093,9 @@
|
||||
}
|
||||
|
||||
// 更新成交量数据
|
||||
const volumes = useElementPeriod ?
|
||||
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 => {
|
||||
let volumes = [];
|
||||
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,
|
||||
@@ -3020,13 +3103,23 @@
|
||||
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) {
|
||||
tvWidget.series.volumeSeries.setData(volumes);
|
||||
}
|
||||
|
||||
// 更新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数据
|
||||
const macdData = [];
|
||||
const signalData = [];
|
||||
@@ -3521,8 +3614,11 @@
|
||||
tables.kline.clear().destroy();
|
||||
}
|
||||
|
||||
// 检查数据是否存在且为数组
|
||||
const klineData = (data.kline_data && Array.isArray(data.kline_data)) ? data.kline_data : [];
|
||||
|
||||
tables.kline = $('#klineTable').DataTable({
|
||||
data: data.kline_data,
|
||||
data: klineData,
|
||||
order: [[0, 'desc']],
|
||||
pageLength: 25,
|
||||
columns: [
|
||||
@@ -3659,15 +3755,18 @@
|
||||
tables.macd.clear().destroy();
|
||||
}
|
||||
|
||||
const macdData = data.kline_data.map((item, index) => {
|
||||
return {
|
||||
time: item.date,
|
||||
close: item.close,
|
||||
macd: data.macd.macd[index],
|
||||
signal: data.macd.signal[index],
|
||||
histogram: data.macd.histogram[index]
|
||||
};
|
||||
});
|
||||
let macdData = [];
|
||||
if (data.kline_data && Array.isArray(data.kline_data) && data.macd && data.macd.macd) {
|
||||
macdData = data.kline_data.map((item, index) => {
|
||||
return {
|
||||
time: item.date,
|
||||
close: item.close,
|
||||
macd: data.macd.macd[index],
|
||||
signal: data.macd.signal[index],
|
||||
histogram: data.macd.histogram[index]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
tables.macd = $('#macdTable').DataTable({
|
||||
data: macdData,
|
||||
@@ -3786,8 +3885,36 @@
|
||||
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');
|
||||
$('#astockSymbol').val('000001');
|
||||
$('#timeframe').val('5m');
|
||||
$('#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>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user