refactor: 缠论引擎包化与 Web 分层(ECR-001)
将根目录引擎迁入 chanlun/ 并保留兼容 shim;拆分 TF_DF 与 web 服务; 前端模块化;strategies 改用 chanlun 导入;补充 ESS 文档与 golden 回归。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,660 @@
|
||||
"""分析 API。"""
|
||||
from flask import Blueprint, jsonify, request
|
||||
from services.runtime import * # noqa: F403
|
||||
from services import runtime as R
|
||||
|
||||
bp = Blueprint("analyze", __name__)
|
||||
|
||||
@bp.route('/api/analyze')
|
||||
def analyze():
|
||||
"""分析接口"""
|
||||
symbol = request.args.get('symbol', 'SOL/USDT:USDT')
|
||||
timeframe = request.args.get('timeframe', '5m')
|
||||
|
||||
# 验证交易对不为空
|
||||
if not symbol or symbol.strip() == '':
|
||||
return jsonify({'error': '交易对不能为空'})
|
||||
|
||||
# 获取时间范围参数
|
||||
start_time = request.args.get('start_time')
|
||||
end_time = request.args.get('end_time')
|
||||
|
||||
# 获取客户端请求的时区
|
||||
client_timezone = request.args.get('timezone', 'Asia/Shanghai')
|
||||
|
||||
# 获取分形元素时间周期与次次周期
|
||||
element_timeframe = request.args.get('element_timeframe')
|
||||
sub_sub_timeframe = request.args.get('sub_sub_timeframe')
|
||||
|
||||
# 获取是否只需要分形元素数据的参数
|
||||
elements_only_param = request.args.get('elements_only')
|
||||
elements_only = elements_only_param == 'true'
|
||||
|
||||
# 验证小周期是否小于主周期
|
||||
if element_timeframe and not is_smaller_or_equal_timeframe(element_timeframe, timeframe):
|
||||
return jsonify({'error': '分形元素时间周期必须小于或等于主图表时间周期'})
|
||||
# 验证次次周期是否小于等于次周期
|
||||
if sub_sub_timeframe and element_timeframe and not is_smaller_or_equal_timeframe(sub_sub_timeframe, element_timeframe):
|
||||
return jsonify({'error': '次次周期必须小于或等于次周期'})
|
||||
|
||||
# 获取数据
|
||||
df = get_kl_data(symbol, timeframe, start_time=start_time, end_time=end_time)
|
||||
if df is None:
|
||||
return jsonify({'error': '获取数据失败'})
|
||||
|
||||
if len(df) == 0:
|
||||
return jsonify({'error': '所选时间范围内没有数据'})
|
||||
|
||||
# 使用客户端指定的时区
|
||||
client_tz = timezone(client_timezone)
|
||||
|
||||
# 如果只需要分形元素数据而不需要主周期数据,则初始化一个空结果
|
||||
result = {
|
||||
'timezone': client_timezone
|
||||
}
|
||||
|
||||
# 如果不是只需要分形元素数据,则添加主周期数据
|
||||
if not elements_only:
|
||||
# 添加技术指标(包括布林带)
|
||||
df = add_indicators(df)
|
||||
|
||||
# 进行缠论分析
|
||||
analysis_result = analyze_chan(df, symbol, timeframe)
|
||||
|
||||
# 计算MACD
|
||||
macd_data = calculate_macd(df)
|
||||
|
||||
# 基于已有 KLC 列表生成趋势标记(不做额外计算)
|
||||
klc_trend = []
|
||||
try:
|
||||
for klc in analysis_result.get('klc_list', []):
|
||||
trend_val = getattr(klc, 'trend', None)
|
||||
t_obj = getattr(klc, 'end_time', None) or getattr(klc, 'start_time', None)
|
||||
if trend_val is None or t_obj is None:
|
||||
continue
|
||||
# 统一成字符串:UP/DOWN/FLAT/UNKNOWN
|
||||
trend_name = str(trend_val)
|
||||
if '.' in trend_name:
|
||||
trend_name = trend_name.split('.')[-1]
|
||||
time_str = format_time_safely(t_obj, client_tz)
|
||||
if time_str:
|
||||
klc_trend.append({'time': time_str, 'trend': trend_name})
|
||||
except Exception:
|
||||
klc_trend = []
|
||||
|
||||
# 添加主周期分析结果到返回数据
|
||||
result.update({
|
||||
'kline_data': clean_dataframe_for_json(df).to_dict('records'),
|
||||
'klc_list': [{
|
||||
'date': klc.end_time if isinstance(klc.end_time, str) else klc.end_time.astimezone(client_tz).isoformat(),
|
||||
'open': float(klc.open),
|
||||
'high': float(klc.high),
|
||||
'low': float(klc.low),
|
||||
'close': float(klc.close),
|
||||
'volume': float(klc.volume) if hasattr(klc, 'volume') else 0,
|
||||
'direction': str(klc.dir).replace('Chan_KLINE_DIR.', ''),
|
||||
'fx_type': str(klc.fx).replace('Chan_FX_TYPE.', ''),
|
||||
'klc_fx_type': str(klc.klc_fx_type).replace('Chan_KLC_FX.', ''),
|
||||
'trend': str(klc.trend).replace('Chan_PRICE_TREND.', '')
|
||||
} for klc in analysis_result['klc_list'] if hasattr(klc, 'end_time') and klc.end_time],
|
||||
'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,
|
||||
'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None,
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
} for bi in analysis_result['bi_list'] if bi.is_sure],
|
||||
# 添加未完成笔列表
|
||||
'uncompleted_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_time, # 未完成笔没有结束时间
|
||||
'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None,
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
} for bi in analysis_result['bi_list'] if not bi.is_sure],
|
||||
'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(),
|
||||
'end_time': (seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()) if seg.end_bi else None,
|
||||
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
|
||||
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
|
||||
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
|
||||
'direction': convert_direction(seg.dir)
|
||||
} for seg in analysis_result['seg_list'] if seg.is_sure],
|
||||
# 添加未完成线段列表
|
||||
'uncompleted_seg_list': get_uncompleted_seg_list(analysis_result['seg_list'], client_tz),
|
||||
'zs_list': [{
|
||||
'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||
'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None,
|
||||
'zg': zs.zg,
|
||||
'zd': zs.zd,
|
||||
'gg': zs.gg,
|
||||
'dd': zs.dd,
|
||||
'is_sure': zs.is_sure # 添加中枢是否完成的标志
|
||||
} for zs in analysis_result['zs_list'] if zs.is_sure],
|
||||
# 添加主周期BI中枢列表(已完成)
|
||||
'bi_zs_list': [{
|
||||
'start_time': (
|
||||
(zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat())
|
||||
if getattr(zs.start_klc, 'end_time', None) else
|
||||
(zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())
|
||||
),
|
||||
'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None,
|
||||
'zg': zs.zg,
|
||||
'zd': zs.zd,
|
||||
'gg': zs.gg,
|
||||
'dd': zs.dd,
|
||||
'is_sure': bool(getattr(zs, 'is_sure', False))
|
||||
} for zs in analysis_result.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)],
|
||||
# 添加未完成中枢列表
|
||||
'uncompleted_zs_list': [{
|
||||
'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||
'end_time': None, # 未完成中枢没有结束时间
|
||||
'zg': zs.zg,
|
||||
'zd': zs.zd,
|
||||
'gg': zs.gg,
|
||||
'dd': zs.dd,
|
||||
'is_sure': zs.is_sure # 未完成中枢的is_sure为False
|
||||
} for zs in analysis_result['zs_list'] if not zs.is_sure],
|
||||
# 添加未完成BI中枢列表
|
||||
'uncompleted_bi_zs_list': [{
|
||||
'start_time': (
|
||||
(zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat())
|
||||
if getattr(zs.start_klc, 'end_time', None) else
|
||||
(zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())
|
||||
),
|
||||
'end_time': None,
|
||||
'zg': zs.zg,
|
||||
'zd': zs.zd,
|
||||
'gg': zs.gg,
|
||||
'dd': zs.dd,
|
||||
'is_sure': bool(getattr(zs, 'is_sure', False))
|
||||
} for zs in analysis_result.get('bi_zs_list', []) if not getattr(zs, 'is_sure', False)],
|
||||
|
||||
'macd': macd_data,
|
||||
# 添加布林带数据
|
||||
'bollinger': {
|
||||
'upper': df['bb_upper'].tolist(),
|
||||
'middle': df['bb_middle'].tolist(),
|
||||
'lower': df['bb_lower'].tolist()
|
||||
},
|
||||
'element_bollinger': {
|
||||
'upper': df['element_bb_upper'].tolist(),
|
||||
'middle': df['element_bb_middle'].tolist(),
|
||||
'lower': df['element_bb_lower'].tolist()
|
||||
},
|
||||
# 添加ATR数据
|
||||
'atr': df['atr'].tolist(),
|
||||
# 添加K线分型信息
|
||||
'klc_fx_info': [{
|
||||
'time': format_time_safely(point['time'], client_tz),
|
||||
'start_time': format_time_safely(point['start_time'], client_tz),
|
||||
'end_time': format_time_safely(point['end_time'], client_tz),
|
||||
'price': float(point['price']),
|
||||
'fx_type': point['fx_type'],
|
||||
'is_bottom': bool(point['is_bottom']),
|
||||
'fx_strength': float(point['fx_strength']), # 分型强度分数
|
||||
'fx_strength_level': str(point['fx_strength_level']), # 分型强度等级
|
||||
'is_strong_fx': bool(point['is_strong_fx']), # 是否为强分型
|
||||
# 分型框(虚线矩形)用到的高低价
|
||||
'high': float(point['high']) if point.get('high') is not None else None,
|
||||
'low': float(point['low']) if point.get('low') is not None else None
|
||||
} for point in analysis_result['klc_fx_info']],
|
||||
# 添加ChanMACD分析数据
|
||||
'chan_macd': serialize_chan_macd_data(analysis_result.get('chan_macd', {}), client_tz),
|
||||
# 添加多时间周期EMA52数据
|
||||
'ema52_dict': analysis_result.get('ema52_dict', {}),
|
||||
# 直接输出KLC趋势标记(使用已有trend字段)
|
||||
'klc_trend': klc_trend,
|
||||
# 添加主周期买卖点列表
|
||||
# 注意:部分枚举在转为字符串时可能形如 "Chan_BSP_TYPE.BSP1(1)",
|
||||
# 这里进行健壮的解析,确保前端拿到的始终是 "BSP1" / "BUY" 这种简洁形式,
|
||||
# 以便与前端的 BSP_STYLE 键(如 "BSP1_BUY")正确匹配。
|
||||
'bsp_list': [{
|
||||
'time': format_time_safely(bsp.end_time, client_tz),
|
||||
'price': float(bsp.klc.low if 'BUY' in str(bsp.dir) else bsp.klc.high),
|
||||
# -- 规范化 type 名称,例如:
|
||||
# "Chan_BSP_TYPE.BSP1" -> "BSP1"
|
||||
# "Chan_BSP_TYPE.BSP1(1)" -> "BSP1"
|
||||
# "BSP1" -> "BSP1"
|
||||
'type': (
|
||||
lambda raw: (
|
||||
(raw.split('.')[-1] if '.' in raw else raw).split('(')[0]
|
||||
)
|
||||
)(str(bsp.type)),
|
||||
# -- 规范化 dir 名称,例如:
|
||||
# "Chan_BSP_DIR.BUY" -> "BUY"
|
||||
# "Chan_BSP_DIR.BUY(1)" -> "BUY"
|
||||
# "BUY" -> "BUY"
|
||||
'dir': (
|
||||
lambda raw: (
|
||||
(raw.split('.')[-1] if '.' in raw else raw).split('(')[0]
|
||||
)
|
||||
)(str(bsp.dir)),
|
||||
'is_sure': bool(bsp.is_sure),
|
||||
'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None,
|
||||
'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0
|
||||
} for bsp in analysis_result.get('bsp_list', [])]
|
||||
})
|
||||
|
||||
|
||||
# 如果有指定分形元素时间周期,获取小周期数据
|
||||
if element_timeframe:
|
||||
# 获取小周期数据,使用与主周期相同的时间范围
|
||||
element_df = get_kl_data(symbol, element_timeframe, start_time=start_time, end_time=end_time)
|
||||
|
||||
if element_df is not None and len(element_df) > 0:
|
||||
# 添加小周期技术指标(包括布林带)
|
||||
element_df = add_indicators(element_df)
|
||||
|
||||
# 对小周期数据进行缠论分析
|
||||
element_analysis = analyze_chan(element_df, symbol, element_timeframe)
|
||||
|
||||
# 计算小周期MACD数据
|
||||
element_macd_data = calculate_macd(element_df)
|
||||
|
||||
# 组装小周期 KLC 趋势(仅提取已有 trend,不做重算)
|
||||
try:
|
||||
element_klc_trend = []
|
||||
for klc in element_analysis.get('klc_list', []):
|
||||
trend_val = getattr(klc, 'trend', None)
|
||||
t_obj = getattr(klc, 'end_time', None) or getattr(klc, 'start_time', None)
|
||||
if trend_val is None or t_obj is None:
|
||||
continue
|
||||
trend_name = str(trend_val)
|
||||
if '.' in trend_name:
|
||||
trend_name = trend_name.split('.')[-1]
|
||||
time_str = format_time_safely(t_obj, client_tz)
|
||||
if time_str:
|
||||
element_klc_trend.append({'time': time_str, 'trend': trend_name})
|
||||
except Exception:
|
||||
element_klc_trend = []
|
||||
|
||||
# 添加小周期分析结果到返回数据
|
||||
result['element_timeframe'] = element_timeframe
|
||||
result['element_macd'] = element_macd_data # 添加小周期MACD数据
|
||||
|
||||
# 添加小周期布林带数据
|
||||
result['element_bollinger'] = {
|
||||
'upper': element_df['bb_upper'].tolist(),
|
||||
'middle': element_df['bb_middle'].tolist(),
|
||||
'lower': element_df['bb_lower'].tolist()
|
||||
}
|
||||
result['element_element_bollinger'] = {
|
||||
'upper': element_df['element_bb_upper'].tolist(),
|
||||
'middle': element_df['element_bb_middle'].tolist(),
|
||||
'lower': element_df['element_bb_lower'].tolist()
|
||||
}
|
||||
|
||||
# 添加小周期ATR数据
|
||||
result['element_atr'] = element_df['atr'].tolist()
|
||||
|
||||
result['element_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,
|
||||
'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None,
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
} for bi in element_analysis['bi_list'] if bi.is_sure]
|
||||
# 添加次周期未完成笔列表
|
||||
result['element_uncompleted_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_time, # 未完成笔没有结束时间
|
||||
'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None,
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
} for bi in element_analysis['bi_list'] if not bi.is_sure]
|
||||
|
||||
# 添加小周期K线数据
|
||||
result['element_kline_data'] = clean_dataframe_for_json(element_df).to_dict('records')
|
||||
|
||||
# 添加小周期KLC列表
|
||||
result['element_klc_list'] = [{
|
||||
'date': klc.end_time if isinstance(klc.end_time, str) else klc.end_time.astimezone(client_tz).isoformat(),
|
||||
'open': float(klc.open),
|
||||
'high': float(klc.high),
|
||||
'low': float(klc.low),
|
||||
'close': float(klc.close),
|
||||
'volume': float(klc.volume) if hasattr(klc, 'volume') else 0,
|
||||
'direction': str(klc.dir).replace('Chan_KLINE_DIR.', ''),
|
||||
'fx_type': str(klc.fx).replace('Chan_FX_TYPE.', ''),
|
||||
'klc_fx_type': str(klc.klc_fx_type).replace('Chan_KLC_FX.', ''),
|
||||
'trend': str(klc.trend).replace('Chan_PRICE_TREND.', '')
|
||||
} for klc in element_analysis['klc_list'] if hasattr(klc, 'end_time') and klc.end_time]
|
||||
|
||||
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(),
|
||||
'end_time': (seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()) if seg.end_bi else None,
|
||||
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
|
||||
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
|
||||
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
|
||||
'direction': convert_direction(seg.dir)
|
||||
} for seg in element_analysis['seg_list'] if seg.is_sure]
|
||||
# 添加次周期未完成线段列表
|
||||
result['element_uncompleted_seg_list'] = get_uncompleted_seg_list(element_analysis['seg_list'], client_tz)
|
||||
|
||||
result['element_zs_list'] = [{
|
||||
'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||
'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None,
|
||||
'zg': zs.zg,
|
||||
'zd': zs.zd,
|
||||
'gg': zs.gg,
|
||||
'dd': zs.dd,
|
||||
'is_sure': zs.is_sure # 添加中枢是否完成的标志
|
||||
} for zs in element_analysis['zs_list'] if zs.end_klc]
|
||||
|
||||
result['element_uncompleted_zs_list'] = [{
|
||||
'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||
'end_time': None, # 未完成中枢没有结束时间
|
||||
'zg': zs.zg,
|
||||
'zd': zs.zd,
|
||||
'gg': zs.gg,
|
||||
'dd': zs.dd,
|
||||
'is_sure': zs.is_sure # 未完成中枢的is_sure为False
|
||||
} for zs in element_analysis['zs_list'] if not zs.is_sure]
|
||||
|
||||
# 添加次周期 BI 中枢(已完成/未完成)
|
||||
result['element_bi_zs_list'] = [{
|
||||
'start_time': (
|
||||
(zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat())
|
||||
if getattr(zs.start_klc, 'end_time', None) else
|
||||
(zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())
|
||||
),
|
||||
'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None,
|
||||
'zg': zs.zg,
|
||||
'zd': zs.zd,
|
||||
'gg': zs.gg,
|
||||
'dd': zs.dd,
|
||||
'is_sure': bool(getattr(zs, 'is_sure', False))
|
||||
} for zs in element_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)]
|
||||
result['element_uncompleted_bi_zs_list'] = [{
|
||||
'start_time': (
|
||||
(zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat())
|
||||
if getattr(zs.start_klc, 'end_time', None) else
|
||||
(zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())
|
||||
),
|
||||
'end_time': None,
|
||||
'zg': zs.zg,
|
||||
'zd': zs.zd,
|
||||
'gg': zs.gg,
|
||||
'dd': zs.dd,
|
||||
'is_sure': bool(getattr(zs, 'is_sure', False))
|
||||
} for zs in element_analysis.get('bi_zs_list', []) if not getattr(zs, 'is_sure', False)]
|
||||
|
||||
|
||||
# 添加小周期分型信息
|
||||
result['element_klc_fx_info'] = [{
|
||||
'time': format_time_safely(point['time'], client_tz),
|
||||
'start_time': format_time_safely(point['start_time'], client_tz),
|
||||
'end_time': format_time_safely(point['end_time'], client_tz),
|
||||
'price': float(point['price']),
|
||||
'fx_type': point['fx_type'],
|
||||
'is_bottom': bool(point['is_bottom']),
|
||||
'fx_strength': float(point['fx_strength']), # 分型强度分数
|
||||
'fx_strength_level': str(point['fx_strength_level']), # 分型强度等级
|
||||
'is_strong_fx': bool(point['is_strong_fx']), # 是否为强分型
|
||||
# 分型框(虚线矩形)用到的高低价
|
||||
'high': float(point['high']) if point.get('high') is not None else None,
|
||||
'low': float(point['low']) if point.get('low') is not None else None
|
||||
} for point in element_analysis['klc_fx_info']]
|
||||
|
||||
# 添加次周期ChanMACD分析数据
|
||||
result['element_chan_macd'] = serialize_chan_macd_data(element_analysis.get('chan_macd', {}), client_tz)
|
||||
|
||||
# 添加小周期 KLC 趋势标记
|
||||
result['element_klc_trend'] = element_klc_trend
|
||||
|
||||
# 添加次周期买卖点列表
|
||||
result['element_bsp_list'] = [{
|
||||
'time': format_time_safely(bsp.end_time, client_tz),
|
||||
'price': float(bsp.klc.low if str(bsp.dir) == 'Chan_BSP_DIR.BUY' else bsp.klc.high),
|
||||
'type': str(bsp.type).replace('Chan_BSP_TYPE.', ''),
|
||||
'dir': str(bsp.dir).replace('Chan_BSP_DIR.', ''),
|
||||
'is_sure': bool(bsp.is_sure),
|
||||
'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None,
|
||||
'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0
|
||||
} for bsp in element_analysis.get('bsp_list', [])]
|
||||
|
||||
# 次次周期:仅当已指定次周期且次次周期有效时获取
|
||||
if sub_sub_timeframe and is_smaller_or_equal_timeframe(sub_sub_timeframe, element_timeframe):
|
||||
sub_sub_df = get_kl_data(symbol, sub_sub_timeframe, start_time=start_time, end_time=end_time)
|
||||
if sub_sub_df is not None and len(sub_sub_df) > 0:
|
||||
sub_sub_df = add_indicators(sub_sub_df)
|
||||
sub_sub_analysis = analyze_chan(sub_sub_df, symbol, sub_sub_timeframe)
|
||||
result['sub_sub_timeframe'] = sub_sub_timeframe
|
||||
result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records')
|
||||
result['sub_sub_atr'] = sub_sub_df['atr'].tolist()
|
||||
result['sub_sub_macd'] = calculate_macd(sub_sub_df)
|
||||
result['sub_sub_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,
|
||||
'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None,
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
} for bi in sub_sub_analysis['bi_list'] if bi.is_sure]
|
||||
result['sub_sub_uncompleted_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_time,
|
||||
'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None,
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high,
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
} for bi in sub_sub_analysis['bi_list'] if not bi.is_sure]
|
||||
# 次次周期 KLC 列表
|
||||
result['sub_sub_klc_list'] = [{
|
||||
'date': klc.end_time if isinstance(klc.end_time, str) else klc.end_time.astimezone(client_tz).isoformat(),
|
||||
'open': float(klc.open),
|
||||
'high': float(klc.high),
|
||||
'low': float(klc.low),
|
||||
'close': float(klc.close),
|
||||
'volume': float(klc.volume) if hasattr(klc, 'volume') else 0,
|
||||
'direction': str(klc.dir).replace('Chan_KLINE_DIR.', ''),
|
||||
'fx_type': str(klc.fx).replace('Chan_FX_TYPE.', ''),
|
||||
'klc_fx_type': str(klc.klc_fx_type).replace('Chan_KLC_FX.', ''),
|
||||
'trend': str(klc.trend).replace('Chan_PRICE_TREND.', '')
|
||||
} for klc in sub_sub_analysis.get('klc_list', []) if hasattr(klc, 'end_time') and klc.end_time]
|
||||
|
||||
result['sub_sub_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(),
|
||||
'end_time': (seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()) if seg.end_bi else None,
|
||||
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
|
||||
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
|
||||
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
|
||||
'direction': convert_direction(seg.dir)
|
||||
} for seg in sub_sub_analysis['seg_list'] if seg.is_sure]
|
||||
result['sub_sub_uncompleted_seg_list'] = get_uncompleted_seg_list(sub_sub_analysis['seg_list'], client_tz)
|
||||
result['sub_sub_zs_list'] = [{
|
||||
'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||
'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None,
|
||||
'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': zs.is_sure
|
||||
} for zs in sub_sub_analysis['zs_list'] if zs.end_klc]
|
||||
result['sub_sub_uncompleted_zs_list'] = [{
|
||||
'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||
'end_time': None, 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': zs.is_sure
|
||||
} for zs in sub_sub_analysis['zs_list'] if not zs.is_sure]
|
||||
result['sub_sub_bi_zs_list'] = [{
|
||||
'start_time': (
|
||||
(zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat())
|
||||
if getattr(zs.start_klc, 'end_time', None) else
|
||||
(zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())
|
||||
),
|
||||
'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None,
|
||||
'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': bool(getattr(zs, 'is_sure', False))
|
||||
} for zs in sub_sub_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)]
|
||||
result['sub_sub_uncompleted_bi_zs_list'] = [{
|
||||
'start_time': (
|
||||
(zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat())
|
||||
if getattr(zs.start_klc, 'end_time', None) else
|
||||
(zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())
|
||||
),
|
||||
'end_time': None, 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': bool(getattr(zs, 'is_sure', False))
|
||||
} for zs in sub_sub_analysis.get('bi_zs_list', []) if not getattr(zs, 'is_sure', False)]
|
||||
result['sub_sub_klc_fx_info'] = [{
|
||||
'time': format_time_safely(point['time'], client_tz),
|
||||
'start_time': format_time_safely(point['start_time'], client_tz),
|
||||
'end_time': format_time_safely(point['end_time'], client_tz),
|
||||
'price': float(point['price']),
|
||||
'fx_type': point['fx_type'],
|
||||
'is_bottom': bool(point['is_bottom']),
|
||||
'fx_strength': float(point['fx_strength']),
|
||||
'fx_strength_level': str(point['fx_strength_level']),
|
||||
'is_strong_fx': bool(point['is_strong_fx']),
|
||||
# 分型框(虚线矩形)用到的高低价
|
||||
'high': float(point['high']) if point.get('high') is not None else None,
|
||||
'low': float(point['low']) if point.get('low') is not None else None
|
||||
} for point in sub_sub_analysis['klc_fx_info']]
|
||||
result['sub_sub_bsp_list'] = [{
|
||||
'time': format_time_safely(bsp.end_time, client_tz),
|
||||
'price': float(bsp.klc.low if str(bsp.dir) == 'Chan_BSP_DIR.BUY' else bsp.klc.high),
|
||||
'type': str(bsp.type).replace('Chan_BSP_TYPE.', ''),
|
||||
'dir': str(bsp.dir).replace('Chan_BSP_DIR.', ''),
|
||||
'is_sure': bool(bsp.is_sure),
|
||||
'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None,
|
||||
'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0
|
||||
} for bsp in sub_sub_analysis.get('bsp_list', [])]
|
||||
result['sub_sub_chan_macd'] = serialize_chan_macd_data(sub_sub_analysis.get('chan_macd', {}), client_tz)
|
||||
try:
|
||||
sub_sub_klc_trend = []
|
||||
for klc in sub_sub_analysis.get('klc_list', []):
|
||||
trend_val = getattr(klc, 'trend', None)
|
||||
t_obj = getattr(klc, 'end_time', None) or getattr(klc, 'start_time', None)
|
||||
if trend_val is None or t_obj is None:
|
||||
continue
|
||||
trend_name = str(trend_val)
|
||||
if '.' in trend_name:
|
||||
trend_name = trend_name.split('.')[-1]
|
||||
time_str = format_time_safely(t_obj, client_tz)
|
||||
if time_str:
|
||||
sub_sub_klc_trend.append({'time': time_str, 'trend': trend_name})
|
||||
result['sub_sub_klc_trend'] = sub_sub_klc_trend
|
||||
except Exception:
|
||||
result['sub_sub_klc_trend'] = []
|
||||
|
||||
pass
|
||||
|
||||
# 结构价值区分析(Structure Zone)—— 按需拉取:仅当 include_structure_zones 为真时执行多周期拉取(默认跳过以减轻负载)
|
||||
include_zones_param = request.args.get('include_structure_zones', '')
|
||||
include_structure_zones = str(include_zones_param).lower() in ('1', 'true', 'yes')
|
||||
if include_structure_zones:
|
||||
zone_timeframes_str = request.args.get('zone_timeframes', '')
|
||||
zone_kl_lines = int(request.args.get('zone_kl_lines', 1000))
|
||||
try:
|
||||
zone_config = StructureZoneConfig(kl_lines_per_tf=zone_kl_lines)
|
||||
if zone_timeframes_str:
|
||||
zone_config.zone_timeframes = [t.strip() for t in zone_timeframes_str.split(',') if t.strip()]
|
||||
analyses = {}
|
||||
ema52_dict = {}
|
||||
latest_close = 0.0
|
||||
now = time.time()
|
||||
|
||||
def _fetch_single_tf_zone(tf_name):
|
||||
"""单个时间周期的结构区数据拉取(线程安全)"""
|
||||
cache_key = f"{symbol}:{tf_name}:{zone_kl_lines}"
|
||||
cached = _zone_cache.get(cache_key)
|
||||
if cached and cached['expires'] > now:
|
||||
print(f" 结构区缓存命中: {tf_name}")
|
||||
return {
|
||||
'tf_name': tf_name,
|
||||
'analyses': cached['analyses'],
|
||||
'ema52': cached['ema52'],
|
||||
'close': cached.get('close', 0.0),
|
||||
'cached': True,
|
||||
}
|
||||
|
||||
try:
|
||||
tf_df = get_kl_data(symbol, tf_name, limit=zone_kl_lines)
|
||||
if tf_df is None or len(tf_df) == 0:
|
||||
return None
|
||||
tf_df = add_indicators(tf_df)
|
||||
tf_analysis = analyze_chan(tf_df, symbol, tf_name)
|
||||
zs_serialized = [{
|
||||
'start_time': (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if zs.start_klc else None,
|
||||
'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None,
|
||||
'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd,
|
||||
'is_sure': zs.is_sure
|
||||
} for zs in tf_analysis.get('zs_list', []) if zs.is_sure]
|
||||
bi_zs_serialized = [{
|
||||
'start_time': ((zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if getattr(zs.start_klc, 'end_time', None) else (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())),
|
||||
'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None,
|
||||
'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd,
|
||||
'is_sure': bool(getattr(zs, 'is_sure', False))
|
||||
} for zs in tf_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)]
|
||||
last_ema = tf_df['ema52'].iloc[-1] if 'ema52' in tf_df.columns else 0
|
||||
ema_val = float(last_ema) if last_ema and last_ema > 0 else None
|
||||
last_close = float(tf_df['close'].iloc[-1])
|
||||
tf_result = {
|
||||
'tf_name': tf_name,
|
||||
'analyses': {'zs_list': zs_serialized, 'bi_zs_list': bi_zs_serialized},
|
||||
'ema52': ema_val,
|
||||
'close': last_close,
|
||||
'cached': False,
|
||||
}
|
||||
# 写入缓存
|
||||
_zone_cache[cache_key] = {
|
||||
'analyses': tf_result['analyses'],
|
||||
'ema52': ema_val,
|
||||
'close': last_close,
|
||||
'expires': now + _zone_cache_ttl(tf_name),
|
||||
}
|
||||
print(f" 结构区数据: {tf_name} -> zs={len(zs_serialized)}, bi_zs={len(bi_zs_serialized)}, ema52={ema_val}")
|
||||
return tf_result
|
||||
except Exception as e:
|
||||
print(f" 结构区 {tf_name} 拉取失败: {e}")
|
||||
return None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(zone_config.zone_timeframes)) as executor:
|
||||
futures = {executor.submit(_fetch_single_tf_zone, tf): tf for tf in zone_config.zone_timeframes}
|
||||
for future in as_completed(futures):
|
||||
tf_result = future.result()
|
||||
if tf_result is None:
|
||||
continue
|
||||
tf_name = tf_result['tf_name']
|
||||
analyses[tf_name] = tf_result['analyses']
|
||||
ema52_dict[tf_name] = tf_result['ema52']
|
||||
if tf_result['close'] and (not latest_close or latest_close == 0.0):
|
||||
latest_close = tf_result['close']
|
||||
|
||||
structure_zones = analyze_structure_zones_from_serialized(
|
||||
analyses, ema52_dict, latest_close, config=zone_config
|
||||
)
|
||||
result['structure_zones'] = [{
|
||||
'id': z.id,
|
||||
'lower': z.lower,
|
||||
'upper': z.upper,
|
||||
'center': z.center,
|
||||
'width_pct': z.width_pct,
|
||||
'zone_type': z.zone_type,
|
||||
'timeframes': z.timeframes,
|
||||
'structure_types': z.structure_types,
|
||||
'boundary_types': z.boundary_types,
|
||||
'overlap_count': z.overlap_count,
|
||||
'touch_count': z.touch_count,
|
||||
'recency_score': z.recency_score,
|
||||
'ema52_distance_pct': z.ema52_distance_pct,
|
||||
'ema52_aligned': z.ema52_aligned,
|
||||
'strength_score': z.strength_score,
|
||||
'confidence': z.confidence,
|
||||
'first_seen': z.first_seen,
|
||||
'last_seen': z.last_seen,
|
||||
'metadata': z.metadata,
|
||||
} for z in structure_zones]
|
||||
except Exception as e:
|
||||
print(f"StructureZone 分析出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
result['structure_zones'] = []
|
||||
else:
|
||||
result['structure_zones'] = []
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""页面路由。"""
|
||||
from flask import Blueprint, render_template, send_from_directory
|
||||
from services.runtime import * # noqa: F403
|
||||
from services import runtime as R
|
||||
|
||||
bp = Blueprint("pages", __name__)
|
||||
|
||||
@bp.route('/chan_tv')
|
||||
def chan_tv():
|
||||
"""缠论 TradingView 高级图表页面"""
|
||||
return render_template('chan_tv.html')
|
||||
|
||||
@bp.route('/charting_library/<path:filename>')
|
||||
def serve_charting_library(filename):
|
||||
"""提供 TradingView Charting Library 静态文件"""
|
||||
return send_from_directory('charting_library', filename)
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
"""主页"""
|
||||
refresh_data_service_metadata()
|
||||
tf_map = TIMEFRAMES if TIMEFRAMES else DEFAULT_TIMEFRAME_LABELS.copy()
|
||||
default_main, default_element, default_sub_sub, timeframe_keys = compute_timeframe_defaults(OrderedDict(tf_map))
|
||||
symbols = SYMBOLS if SYMBOLS else DEFAULT_SYMBOLS
|
||||
|
||||
default_symbol = 'BTC/USDT:USDT' if 'BTC/USDT:USDT' in symbols else (symbols[0] if symbols else '')
|
||||
|
||||
return render_template(
|
||||
'index.html',
|
||||
timeframes=tf_map,
|
||||
symbols=symbols,
|
||||
a_stock_symbols=A_STOCK_SYMBOLS,
|
||||
default_main_timeframe=default_main,
|
||||
default_element_timeframe=default_element,
|
||||
default_sub_sub_timeframe=default_sub_sub,
|
||||
default_symbol=default_symbol,
|
||||
timeframe_keys_json=json.dumps(timeframe_keys),
|
||||
data_service_available=DATA_SERVICE_AVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/api/chart_metadata')
|
||||
def api_chart_metadata():
|
||||
"""
|
||||
按数据源返回图表用 K 线周期(中文标签)及主/次/次次默认周期。
|
||||
crypto:强制刷新 DATA_SERVICE_URL /health 元信息;
|
||||
a_stock:读取 ASHARE_DP_URL 的 /api/v1/klines/available-freqs,不修改全局加密货币 TIMEFRAMES。
|
||||
"""
|
||||
source = (request.args.get('source') or 'crypto').strip().lower()
|
||||
if source not in ('crypto', 'a_stock'):
|
||||
source = 'crypto'
|
||||
try:
|
||||
if source == 'a_stock':
|
||||
raw = china_stock.get_available_kline_freqs()
|
||||
labels_od = build_timeframe_labels(raw)
|
||||
else:
|
||||
refresh_data_service_metadata(force=True)
|
||||
labels_od = OrderedDict(TIMEFRAMES if TIMEFRAMES else DEFAULT_TIMEFRAME_LABELS.copy())
|
||||
|
||||
default_main, default_element, default_sub_sub, keys = compute_timeframe_defaults(labels_od)
|
||||
return jsonify({
|
||||
'source': source,
|
||||
'timeframes': {k: v for k, v in labels_od.items()},
|
||||
'timeframe_keys': keys,
|
||||
'default_main': default_main,
|
||||
'default_element': default_element,
|
||||
'default_sub_sub': default_sub_sub,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.exception('chart_metadata 失败: %s', exc)
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""交易对 / A股 / MACD 配置 API。"""
|
||||
from flask import Blueprint, jsonify, request
|
||||
from services.runtime import * # noqa: F403
|
||||
|
||||
bp = Blueprint("symbols", __name__)
|
||||
|
||||
@bp.route('/api/symbols')
|
||||
def get_symbols():
|
||||
"""获取可用交易对"""
|
||||
refresh_data_service_metadata()
|
||||
if SYMBOLS:
|
||||
return jsonify(SYMBOLS)
|
||||
try:
|
||||
markets = exchange.load_markets()
|
||||
# 合约交易对通常是以USDT结尾的永续合约
|
||||
symbols = [symbol for symbol in markets.keys() if '/USDT' in symbol and ':USDT' in symbol]
|
||||
return jsonify(symbols)
|
||||
except Exception as e:
|
||||
return jsonify(DEFAULT_SYMBOLS)
|
||||
|
||||
@bp.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)})
|
||||
|
||||
@bp.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)})
|
||||
|
||||
@bp.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)})
|
||||
|
||||
@bp.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)})
|
||||
|
||||
@bp.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)})
|
||||
|
||||
|
||||
@bp.route('/api/macd_config', methods=['GET', 'POST'])
|
||||
def macd_config():
|
||||
"""获取或设置MACD参数"""
|
||||
global macd_fast_period, macd_slow_period, macd_signal_period
|
||||
if request.method == 'GET':
|
||||
return jsonify({
|
||||
'fast': macd_fast_period,
|
||||
'slow': macd_slow_period,
|
||||
'signal': macd_signal_period
|
||||
})
|
||||
else:
|
||||
data = request.get_json(silent=True) or {}
|
||||
fast = data.get('fast')
|
||||
slow = data.get('slow')
|
||||
signal = data.get('signal')
|
||||
if fast is not None:
|
||||
macd_fast_period = int(fast)
|
||||
if slow is not None:
|
||||
macd_slow_period = int(slow)
|
||||
if signal is not None:
|
||||
macd_signal_period = int(signal)
|
||||
return jsonify({
|
||||
'fast': macd_fast_period,
|
||||
'slow': macd_slow_period,
|
||||
'signal': macd_signal_period
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
"""趋势相关 API。"""
|
||||
from flask import Blueprint, jsonify, request
|
||||
from services.runtime import * # noqa: F403
|
||||
from services import runtime as R
|
||||
|
||||
bp = Blueprint("trend", __name__)
|
||||
|
||||
@bp.route('/api/trend_filter', methods=['GET'])
|
||||
def trend_filter():
|
||||
"""趋势筛选接口(币对)
|
||||
参数:
|
||||
timeframe: K线周期
|
||||
start_time, end_time: 毫秒时间戳,可选
|
||||
direction: bull/bear/sideways 可选
|
||||
stage: early/mid/late 可选
|
||||
min_strength: 0-100 可选
|
||||
symbols: 逗号分隔列表,可选;不传则自动加载部分USDT币对
|
||||
返回符合条件的币对与简要统计
|
||||
"""
|
||||
timeframe = request.args.get('timeframe', '1h')
|
||||
start_time = request.args.get('start_time')
|
||||
end_time = request.args.get('end_time')
|
||||
want_direction = request.args.get('direction') # 可为 None
|
||||
want_stage = request.args.get('stage') # 可为 None
|
||||
try:
|
||||
min_strength = float(request.args.get('min_strength', '0'))
|
||||
except ValueError:
|
||||
min_strength = 0.0
|
||||
|
||||
symbols_param = request.args.get('symbols')
|
||||
if symbols_param:
|
||||
symbols_list = [s.strip() for s in symbols_param.split(',') if s.strip()]
|
||||
else:
|
||||
symbols_list = load_crypto_symbols(limit=150)
|
||||
|
||||
results = []
|
||||
for sym in symbols_list:
|
||||
try:
|
||||
df = get_crypto_kl_data(sym, timeframe, start_time=start_time, end_time=end_time)
|
||||
if df is None or len(df) < 60:
|
||||
continue
|
||||
df = add_indicators(df)
|
||||
direction, stage, strength = classify_trend_stage(df)
|
||||
|
||||
if want_direction and direction != want_direction:
|
||||
continue
|
||||
if want_stage and stage != want_stage:
|
||||
continue
|
||||
if strength < min_strength:
|
||||
continue
|
||||
|
||||
last_row = df.iloc[-1]
|
||||
results.append({
|
||||
'symbol': sym,
|
||||
'time': int(last_row['timestamp']),
|
||||
'close': float(last_row['close']),
|
||||
'direction': direction,
|
||||
'stage': stage,
|
||||
'strength': float(round(strength, 2)),
|
||||
'ema5': float(last_row['ema5']),
|
||||
'ema10': float(last_row['ema10']),
|
||||
'ema24': float(last_row['ema24']),
|
||||
'ema52': float(last_row['ema52'])
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 按强度降序
|
||||
results.sort(key=lambda x: x['strength'], reverse=True)
|
||||
return jsonify({
|
||||
'count': len(results),
|
||||
'results': results
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/api/trend_detail', methods=['GET'])
|
||||
def trend_detail():
|
||||
"""返回单个币对的K线与EMA、用于前端绘制趋势线
|
||||
参数: symbol, timeframe, start_time, end_time
|
||||
"""
|
||||
symbol = request.args.get('symbol')
|
||||
timeframe = request.args.get('timeframe', '1h')
|
||||
start_time = request.args.get('start_time')
|
||||
end_time = request.args.get('end_time')
|
||||
timezone_name = request.args.get('timezone', 'Asia/Shanghai')
|
||||
|
||||
if not symbol:
|
||||
return jsonify({'error': 'symbol不能为空'})
|
||||
|
||||
df = get_crypto_kl_data(symbol, timeframe, start_time=start_time, end_time=end_time)
|
||||
if df is None or len(df) == 0:
|
||||
return jsonify({'error': '获取数据失败'})
|
||||
|
||||
df = add_indicators(df)
|
||||
direction, stage, strength = classify_trend_stage(df)
|
||||
|
||||
# 简单趋势线: 用最近N根收盘价做线性拟合
|
||||
N = min(80, len(df))
|
||||
sub = df.tail(N)
|
||||
y = sub['close'].values
|
||||
x = np.arange(len(y))
|
||||
denom = np.dot(x - x.mean(), x - x.mean())
|
||||
if denom != 0:
|
||||
m = float(np.dot(y - y.mean(), x - x.mean()) / denom)
|
||||
b = float(y.mean() - m * x.mean())
|
||||
else:
|
||||
m, b = 0.0, float(y[-1])
|
||||
|
||||
client_tz = timezone(timezone_name)
|
||||
|
||||
return jsonify({
|
||||
'symbol': symbol,
|
||||
'timeframe': timeframe,
|
||||
'timezone': timezone_name,
|
||||
'direction': direction,
|
||||
'stage': stage,
|
||||
'strength': float(round(strength, 2)),
|
||||
'kline_data': clean_dataframe_for_json(df)[['timestamp','open','high','low','close','volume','ema5','ema10','ema24','ema52']].to_dict('records'),
|
||||
'trend_line': {
|
||||
'offset': int(df.index[-N]),
|
||||
'slope': m,
|
||||
'intercept': b,
|
||||
'length': int(N)
|
||||
}
|
||||
})
|
||||
|
||||
+22
-2099
File diff suppressed because it is too large
Load Diff
+3
-966
@@ -1,966 +1,3 @@
|
||||
import os
|
||||
import akshare as ak
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta, time
|
||||
import time as time_module
|
||||
import traceback
|
||||
from pytz import timezone
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 与 A-Share Data Platform REST 文档一致的周期(分钟线依赖服务端积累,无数据时会回退 AKShare)
|
||||
ASHARE_REST_TIMEFRAMES = frozenset({'1m', '5m', '15m', '30m', '1h', '2h', '1d', '1w', '1M'})
|
||||
|
||||
|
||||
class ChinaStockData:
|
||||
"""A股数据获取类"""
|
||||
|
||||
def __init__(self):
|
||||
self.tz = timezone('Asia/Shanghai')
|
||||
# A股交易时间配置
|
||||
self.trading_hours = {
|
||||
'morning': {'start': '09:30', 'end': '11:30'},
|
||||
'afternoon': {'start': '13:00', 'end': '15:00'}
|
||||
}
|
||||
# 例: http://103.179.242.166:8000 — 设 ASHARE_DP_URL= 空字符串可禁用,仅用 AKShare
|
||||
_base = os.environ.get('ASHARE_DP_URL', 'http://103.179.242.166:8000')
|
||||
self.ashare_dp_base = _base.rstrip('/') if (_base or '').strip() else ''
|
||||
# 全量股票列表内存缓存(秒),默认 1 小时
|
||||
try:
|
||||
self.stock_list_cache_ttl = int(os.environ.get('ASHARE_STOCK_LIST_CACHE_SEC', '3600'))
|
||||
except ValueError:
|
||||
self.stock_list_cache_ttl = 3600
|
||||
self._stock_list_cache = None
|
||||
self._stock_list_cache_expires = 0.0
|
||||
|
||||
def _get_stock_list_akshare(self):
|
||||
"""通过 AKShare 获取 A 股列表(约 2000 条非 ST,作备用)。"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
try:
|
||||
original_timeout = getattr(requests, 'timeout', None)
|
||||
requests.timeout = 10
|
||||
|
||||
stock_info = ak.stock_zh_a_spot_em()
|
||||
|
||||
if original_timeout:
|
||||
requests.timeout = original_timeout
|
||||
else:
|
||||
delattr(requests, 'timeout')
|
||||
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if stock_info is None or len(stock_info) == 0:
|
||||
return []
|
||||
|
||||
stock_list = []
|
||||
for index, row in stock_info.head(2000).iterrows():
|
||||
try:
|
||||
stock_name = str(row['名称'])
|
||||
if 'ST' not in stock_name and '*' not in stock_name:
|
||||
stock_list.append({
|
||||
'symbol': row['代码'],
|
||||
'name': row['名称'],
|
||||
'price': float(row['最新价']) if pd.notna(row['最新价']) else 0.0,
|
||||
'change_pct': float(row['涨跌幅']) if pd.notna(row['涨跌幅']) else 0.0,
|
||||
'volume': float(row['成交量']) if pd.notna(row['成交量']) else 0.0,
|
||||
'amount': float(row['成交额']) if pd.notna(row['成交额']) else 0.0
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
stock_list.sort(key=lambda x: x['amount'], reverse=True)
|
||||
return stock_list
|
||||
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _fetch_all_stocks_ashare_dp(self):
|
||||
"""分页拉取 A-Share Data Platform /api/v1/stocks 全市场标的。"""
|
||||
import requests
|
||||
|
||||
page_size = 1000
|
||||
offset = 0
|
||||
all_rows = []
|
||||
reported_total = None
|
||||
url = f'{self.ashare_dp_base}/api/v1/stocks'
|
||||
while True:
|
||||
resp = requests.get(
|
||||
url,
|
||||
params={'limit': page_size, 'offset': offset},
|
||||
timeout=45,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
items = payload.get('items') or []
|
||||
if reported_total is None:
|
||||
reported_total = int(payload.get('total') or 0)
|
||||
all_rows.extend(items)
|
||||
if len(items) == 0:
|
||||
break
|
||||
if len(items) < page_size:
|
||||
break
|
||||
offset += page_size
|
||||
if reported_total and offset >= reported_total:
|
||||
break
|
||||
if not all_rows:
|
||||
return []
|
||||
out = []
|
||||
for row in all_rows:
|
||||
sym = row.get('symbol')
|
||||
if not sym and row.get('ts_code'):
|
||||
sym = str(row['ts_code']).split('.')[0]
|
||||
if not sym:
|
||||
continue
|
||||
name = row.get('name') or ''
|
||||
out.append({
|
||||
'symbol': str(sym).strip(),
|
||||
'name': str(name).strip(),
|
||||
'ts_code': row.get('ts_code'),
|
||||
'price': 0.0,
|
||||
'change_pct': 0.0,
|
||||
'volume': 0.0,
|
||||
'amount': 0.0,
|
||||
})
|
||||
out.sort(key=lambda x: x['symbol'])
|
||||
return out
|
||||
|
||||
def get_stock_list(self, use_cache=True):
|
||||
"""获取 A 股股票列表:优先全量 REST(约 5500+),失败则 AKShare。"""
|
||||
now = time_module.time()
|
||||
if use_cache and self._stock_list_cache is not None and now < self._stock_list_cache_expires:
|
||||
return list(self._stock_list_cache)
|
||||
|
||||
if self.ashare_dp_base:
|
||||
try:
|
||||
dp_list = self._fetch_all_stocks_ashare_dp()
|
||||
if dp_list:
|
||||
self._stock_list_cache = dp_list
|
||||
self._stock_list_cache_expires = now + self.stock_list_cache_ttl
|
||||
return list(dp_list)
|
||||
except Exception as exc:
|
||||
logger.warning('A股列表从数据服务拉取失败,回退 AKShare: %s', exc)
|
||||
|
||||
ak_list = self._get_stock_list_akshare()
|
||||
if ak_list:
|
||||
self._stock_list_cache = ak_list
|
||||
self._stock_list_cache_expires = now + min(self.stock_list_cache_ttl, 300)
|
||||
return ak_list or []
|
||||
|
||||
def get_available_kline_freqs(self):
|
||||
"""
|
||||
A-Share Data Platform 支持的 K 线周期列表(原始顺序不保证,由上层按粒度排序)。
|
||||
文档: GET /api/v1/klines/available-freqs
|
||||
"""
|
||||
import requests
|
||||
|
||||
fallback = ['1m', '5m', '15m', '30m', '1h', '2h', '1d', '1w', '1M']
|
||||
if not self.ashare_dp_base:
|
||||
return list(fallback)
|
||||
try:
|
||||
url = f'{self.ashare_dp_base}/api/v1/klines/available-freqs'
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
freqs = data.get('frequencies') or []
|
||||
return list(freqs) if freqs else list(fallback)
|
||||
except Exception as exc:
|
||||
logger.warning('获取 A 股可用 K 线周期失败: %s', exc)
|
||||
return list(fallback)
|
||||
|
||||
def get_popular_stocks(self):
|
||||
"""获取热门A股股票代码列表 - 扩展版本,按行业分类"""
|
||||
return [
|
||||
# 包装引印刷
|
||||
{'symbol': '002836', 'name': '新宏泽', 'sector': '包装印刷'},
|
||||
# 银行股
|
||||
{'symbol': '600036', 'name': '招商银行', 'sector': '银行'},
|
||||
{'symbol': '000001', 'name': '平安银行', 'sector': '银行'},
|
||||
{'symbol': '600000', 'name': '浦发银行', 'sector': '银行'},
|
||||
{'symbol': '002142', 'name': '宁波银行', 'sector': '银行'},
|
||||
{'symbol': '600016', 'name': '民生银行', 'sector': '银行'},
|
||||
{'symbol': '601288', 'name': '农业银行', 'sector': '银行'},
|
||||
{'symbol': '601398', 'name': '工商银行', 'sector': '银行'},
|
||||
{'symbol': '601328', 'name': '交通银行', 'sector': '银行'},
|
||||
|
||||
# 白酒股
|
||||
{'symbol': '600519', 'name': '贵州茅台', 'sector': '白酒'},
|
||||
{'symbol': '000858', 'name': '五粮液', 'sector': '白酒'},
|
||||
{'symbol': '002304', 'name': '洋河股份', 'sector': '白酒'},
|
||||
{'symbol': '000596', 'name': '古井贡酒', 'sector': '白酒'},
|
||||
{'symbol': '603369', 'name': '今世缘', 'sector': '白酒'},
|
||||
{'symbol': '000799', 'name': '酒鬼酒', 'sector': '白酒'},
|
||||
{'symbol': '600809', 'name': '山西汾酒', 'sector': '白酒'},
|
||||
|
||||
# 科技股
|
||||
{'symbol': '002415', 'name': '海康威视', 'sector': '科技'},
|
||||
{'symbol': '000063', 'name': '中兴通讯', 'sector': '科技'},
|
||||
{'symbol': '002475', 'name': '立讯精密', 'sector': '科技'},
|
||||
{'symbol': '300059', 'name': '东方财富', 'sector': '科技'},
|
||||
{'symbol': '000725', 'name': '京东方A', 'sector': '科技'},
|
||||
{'symbol': '002230', 'name': '科大讯飞', 'sector': '科技'},
|
||||
{'symbol': '300433', 'name': '蓝思科技', 'sector': '科技'},
|
||||
{'symbol': '002236', 'name': '大华股份', 'sector': '科技'},
|
||||
|
||||
# 新能源
|
||||
{'symbol': '300750', 'name': '宁德时代', 'sector': '新能源'},
|
||||
{'symbol': '002594', 'name': '比亚迪', 'sector': '新能源'},
|
||||
{'symbol': '300274', 'name': '阳光电源', 'sector': '新能源'},
|
||||
{'symbol': '002460', 'name': '赣锋锂业', 'sector': '新能源'},
|
||||
{'symbol': '300014', 'name': '亿纬锂能', 'sector': '新能源'},
|
||||
{'symbol': '600884', 'name': '杉杉股份', 'sector': '新能源'},
|
||||
{'symbol': '002812', 'name': '恩捷股份', 'sector': '新能源'},
|
||||
|
||||
# 房地产
|
||||
{'symbol': '000002', 'name': '万科A', 'sector': '房地产'},
|
||||
{'symbol': '000858', 'name': '五粮液', 'sector': '房地产'},
|
||||
{'symbol': '600048', 'name': '保利发展', 'sector': '房地产'},
|
||||
{'symbol': '001979', 'name': '招商蛇口', 'sector': '房地产'},
|
||||
{'symbol': '600606', 'name': '绿地控股', 'sector': '房地产'},
|
||||
|
||||
# 消费股
|
||||
{'symbol': '600887', 'name': '伊利股份', 'sector': '消费'},
|
||||
{'symbol': '000568', 'name': '泸州老窖', 'sector': '消费'},
|
||||
{'symbol': '600600', 'name': '青岛啤酒', 'sector': '消费'},
|
||||
{'symbol': '000895', 'name': '双汇发展', 'sector': '消费'},
|
||||
{'symbol': '002304', 'name': '洋河股份', 'sector': '消费'},
|
||||
{'symbol': '600779', 'name': '水井坊', 'sector': '消费'},
|
||||
|
||||
# 医药股
|
||||
{'symbol': '600196', 'name': '复星医药', 'sector': '医药'},
|
||||
{'symbol': '000661', 'name': '长春高新', 'sector': '医药'},
|
||||
{'symbol': '300015', 'name': '爱尔眼科', 'sector': '医药'},
|
||||
{'symbol': '002821', 'name': '凯莱英', 'sector': '医药'},
|
||||
{'symbol': '300760', 'name': '迈瑞医疗', 'sector': '医药'},
|
||||
{'symbol': '600276', 'name': '恒瑞医药', 'sector': '医药'},
|
||||
|
||||
# 证券股
|
||||
{'symbol': '000776', 'name': '广发证券', 'sector': '证券'},
|
||||
{'symbol': '600030', 'name': '中信证券', 'sector': '证券'},
|
||||
{'symbol': '000166', 'name': '申万宏源', 'sector': '证券'},
|
||||
{'symbol': '601688', 'name': '华泰证券', 'sector': '证券'},
|
||||
{'symbol': '600837', 'name': '海通证券', 'sector': '证券'},
|
||||
|
||||
# 化工股
|
||||
{'symbol': '600309', 'name': '万华化学', 'sector': '化工'},
|
||||
{'symbol': '002352', 'name': '顺丰控股', 'sector': '化工'},
|
||||
{'symbol': '600346', 'name': '恒力石化', 'sector': '化工'},
|
||||
{'symbol': '000792', 'name': '盐湖股份', 'sector': '化工'},
|
||||
|
||||
# 汽车股
|
||||
{'symbol': '600104', 'name': '上汽集团', 'sector': '汽车'},
|
||||
{'symbol': '000625', 'name': '长安汽车', 'sector': '汽车'},
|
||||
{'symbol': '601633', 'name': '长城汽车', 'sector': '汽车'},
|
||||
{'symbol': '002049', 'name': '紫光国微', 'sector': '汽车'},
|
||||
|
||||
# 军工股
|
||||
{'symbol': '002179', 'name': '中航光电', 'sector': '军工'},
|
||||
{'symbol': '600893', 'name': '航发动力', 'sector': '军工'},
|
||||
{'symbol': '000768', 'name': '中航飞机', 'sector': '军工'},
|
||||
|
||||
# 基建股
|
||||
{'symbol': '601186', 'name': '中国铁建', 'sector': '基建'},
|
||||
{'symbol': '601390', 'name': '中国中铁', 'sector': '基建'},
|
||||
{'symbol': '000001', 'name': '平安银行', 'sector': '基建'},
|
||||
|
||||
# 煤炭股
|
||||
{'symbol': '601225', 'name': '陕西煤业', 'sector': '煤炭'},
|
||||
{'symbol': '600188', 'name': '兖矿能源', 'sector': '煤炭'},
|
||||
{'symbol': '601898', 'name': '中煤能源', 'sector': '煤炭'},
|
||||
|
||||
# 钢铁股
|
||||
{'symbol': '000717', 'name': '韶钢松山', 'sector': '钢铁'},
|
||||
{'symbol': '600019', 'name': '宝钢股份', 'sector': '钢铁'},
|
||||
{'symbol': '000708', 'name': '中信特钢', 'sector': '钢铁'},
|
||||
]
|
||||
|
||||
def timeframe_to_period(self, timeframe):
|
||||
"""将时间周期转换为akshare的period参数"""
|
||||
mapping = {
|
||||
'1m': '1', # 1分钟
|
||||
'5m': '5', # 5分钟
|
||||
'15m': '15', # 15分钟
|
||||
'30m': '30', # 30分钟
|
||||
'1h': '60', # 60分钟
|
||||
'1d': 'daily', # 日线
|
||||
'1w': 'weekly',# 周线
|
||||
'1M': 'monthly'# 月线
|
||||
}
|
||||
return mapping.get(timeframe, 'daily')
|
||||
|
||||
@staticmethod
|
||||
def symbol_to_ts_code(symbol):
|
||||
"""六位代码或已是 ts_code(000001.SZ)→ 交易所后缀。"""
|
||||
if symbol is None:
|
||||
return ''
|
||||
s = str(symbol).strip().upper()
|
||||
if '.' in s and s.count('.') == 1:
|
||||
return s
|
||||
if len(s) != 6 or not s.isdigit():
|
||||
return s
|
||||
if s.startswith('6'):
|
||||
return f'{s}.SH'
|
||||
if s.startswith(('0', '3')):
|
||||
return f'{s}.SZ'
|
||||
if s.startswith('920'):
|
||||
return f'{s}.BJ'
|
||||
if s.startswith(('8', '4')):
|
||||
return f'{s}.BJ'
|
||||
return f'{s}.SZ'
|
||||
|
||||
@staticmethod
|
||||
def _ymd_compact_to_api_date(ymd_compact):
|
||||
"""YYYYMMDD → YYYY-MM-DD"""
|
||||
if not ymd_compact or len(ymd_compact) != 8:
|
||||
return None
|
||||
return f'{ymd_compact[:4]}-{ymd_compact[4:6]}-{ymd_compact[6:8]}'
|
||||
|
||||
def get_kl_data_from_ashare_dp(self, symbol, timeframe, start_date, end_date, limit):
|
||||
"""
|
||||
从 A-Share Data Platform(/api/v1/klines/{freq})拉取 K 线。
|
||||
start_date / end_date 为 YYYYMMDD 字符串。
|
||||
"""
|
||||
if not self.ashare_dp_base or timeframe not in ASHARE_REST_TIMEFRAMES:
|
||||
return None
|
||||
import requests
|
||||
|
||||
ts_code = self.symbol_to_ts_code(symbol)
|
||||
if not ts_code or '.' not in ts_code:
|
||||
return None
|
||||
start_api = self._ymd_compact_to_api_date(start_date)
|
||||
end_api = self._ymd_compact_to_api_date(end_date)
|
||||
if not start_api or not end_api:
|
||||
return None
|
||||
api_limit = 10000
|
||||
if limit is not None:
|
||||
try:
|
||||
api_limit = min(int(limit), 10000)
|
||||
except (TypeError, ValueError):
|
||||
api_limit = 10000
|
||||
url = f'{self.ashare_dp_base}/api/v1/klines/{timeframe}'
|
||||
params = {
|
||||
'ts_code': ts_code,
|
||||
'start_date': start_api,
|
||||
'end_date': end_api,
|
||||
'limit': api_limit,
|
||||
}
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=20)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except Exception as exc:
|
||||
logger.debug('A股数据服务 K 线请求失败: %s', exc)
|
||||
return None
|
||||
items = payload.get('items') or payload.get('data') or []
|
||||
if not items:
|
||||
return None
|
||||
rows = []
|
||||
for row in items:
|
||||
t = row.get('trade_time') or row.get('trade_date')
|
||||
if not t:
|
||||
continue
|
||||
rows.append({
|
||||
'date': t,
|
||||
'open': row.get('open'),
|
||||
'high': row.get('high'),
|
||||
'low': row.get('low'),
|
||||
'close': row.get('close'),
|
||||
'volume': row.get('volume'),
|
||||
})
|
||||
if not rows:
|
||||
return None
|
||||
df = pd.DataFrame(rows)
|
||||
df['date'] = pd.to_datetime(df['date'])
|
||||
for col in ('open', 'high', 'low', 'close', 'volume'):
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_numeric(df[col], errors='coerce')
|
||||
df = df.dropna(subset=['open', 'high', 'low', 'close'])
|
||||
df = df.sort_values('date').reset_index(drop=True)
|
||||
df = self.adjust_timestamp_for_trading_hours(df, timeframe)
|
||||
df = self.clean_a_stock_data(df, timeframe)
|
||||
if df is None or len(df) == 0:
|
||||
return None
|
||||
if limit is not None:
|
||||
try:
|
||||
lim = int(limit)
|
||||
if len(df) > lim:
|
||||
df = df.tail(lim).reset_index(drop=True)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
elif len(df) > 10000:
|
||||
df = df.tail(10000).reset_index(drop=True)
|
||||
df = self.add_indicators(df)
|
||||
return df
|
||||
|
||||
def get_kl_data(self, symbol, timeframe='1d', start_date=None, end_date=None, limit=10000):
|
||||
"""
|
||||
获取A股K线数据 - 支持分批次获取突破单次限制
|
||||
:param symbol: 股票代码,如 '000001'
|
||||
:param timeframe: 时间周期,如 '1d', '1h', '5m'
|
||||
:param start_date: 开始日期,格式 'YYYY-MM-DD'
|
||||
:param end_date: 结束日期,格式 'YYYY-MM-DD'
|
||||
:param limit: 数据条数限制
|
||||
:return: DataFrame
|
||||
"""
|
||||
try:
|
||||
period = self.timeframe_to_period(timeframe)
|
||||
|
||||
# 处理时间参数
|
||||
if start_date is None:
|
||||
# 默认获取最近一年的数据
|
||||
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y%m%d')
|
||||
else:
|
||||
# 将 YYYY-MM-DD 格式转换为 YYYYMMDD
|
||||
if '-' in start_date:
|
||||
start_date = start_date.replace('-', '')
|
||||
|
||||
if end_date is None:
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
else:
|
||||
if '-' in end_date:
|
||||
end_date = end_date.replace('-', '')
|
||||
|
||||
if self.ashare_dp_base:
|
||||
df_dp = self.get_kl_data_from_ashare_dp(
|
||||
symbol, timeframe, start_date, end_date, limit
|
||||
)
|
||||
if df_dp is not None and len(df_dp) > 0:
|
||||
return df_dp
|
||||
|
||||
# 分批次获取数据以突破单次限制
|
||||
all_data = []
|
||||
current_start = start_date
|
||||
|
||||
# 计算时间间隔(根据时间周期调整批次大小)
|
||||
if period in ['1', '5', '15', '30']:
|
||||
# 分钟级数据,每次获取7天
|
||||
batch_days = 7
|
||||
elif period == '60':
|
||||
# 小时级数据,每次获取30天
|
||||
batch_days = 30
|
||||
else:
|
||||
# 日线及以上,每次获取365天
|
||||
batch_days = 365
|
||||
|
||||
max_iterations = 20 # 最大迭代次数,防止无限循环
|
||||
iteration_count = 0
|
||||
|
||||
while current_start <= end_date and iteration_count < max_iterations:
|
||||
iteration_count += 1
|
||||
|
||||
# 计算当前批次的结束时间
|
||||
current_start_dt = datetime.strptime(current_start, '%Y%m%d')
|
||||
current_end_dt = current_start_dt + timedelta(days=batch_days)
|
||||
current_end = min(current_end_dt.strftime('%Y%m%d'), end_date)
|
||||
|
||||
pass
|
||||
|
||||
try:
|
||||
# 根据时间周期选择不同的API
|
||||
df_batch = None
|
||||
if period in ['1', '5', '15', '30', '60']:
|
||||
# 分钟级数据
|
||||
df_batch = ak.stock_zh_a_hist_min_em(symbol=symbol, period=period,
|
||||
start_date=current_start, end_date=current_end)
|
||||
if df_batch is not None and len(df_batch) > 0:
|
||||
# 重命名列
|
||||
df_batch = df_batch.rename(columns={
|
||||
'时间': 'date',
|
||||
'开盘': 'open',
|
||||
'收盘': 'close',
|
||||
'最高': 'high',
|
||||
'最低': 'low',
|
||||
'成交量': 'volume'
|
||||
})
|
||||
else:
|
||||
# 日线、周线、月线数据
|
||||
df_batch = ak.stock_zh_a_hist(symbol=symbol, period=period,
|
||||
start_date=current_start, end_date=current_end)
|
||||
if df_batch is not None and len(df_batch) > 0:
|
||||
# 重命名列
|
||||
df_batch = df_batch.rename(columns={
|
||||
'日期': 'date',
|
||||
'开盘': 'open',
|
||||
'收盘': 'close',
|
||||
'最高': 'high',
|
||||
'最低': 'low',
|
||||
'成交量': 'volume'
|
||||
})
|
||||
|
||||
if df_batch is not None and len(df_batch) > 0:
|
||||
# 转换时间格式
|
||||
df_batch['date'] = pd.to_datetime(df_batch['date'])
|
||||
|
||||
# 根据A股交易时间调整时间戳
|
||||
df_batch = self.adjust_timestamp_for_trading_hours(df_batch, timeframe)
|
||||
|
||||
all_data.append(df_batch)
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
# 继续下一个批次
|
||||
pass
|
||||
|
||||
# 更新下一批次的开始时间
|
||||
current_start = (current_end_dt + timedelta(days=1)).strftime('%Y%m%d')
|
||||
|
||||
# 防止API请求过于频繁
|
||||
time_module.sleep(0.5)
|
||||
|
||||
# 合并所有批次的数据
|
||||
if not all_data:
|
||||
return None
|
||||
|
||||
# 合并DataFrame
|
||||
df = pd.concat(all_data, ignore_index=True)
|
||||
|
||||
# 数据清洗和格式化
|
||||
df = df.dropna() # 删除空值
|
||||
df = df.drop_duplicates(subset=['date']) # 删除重复数据
|
||||
df = df.sort_values('date').reset_index(drop=True) # 按时间排序
|
||||
|
||||
# A股特有的数据清理和时间处理
|
||||
df = self.clean_a_stock_data(df, timeframe)
|
||||
|
||||
# 限制数据条数 - 只有在没有指定明确时间范围时才应用
|
||||
# 如果用户指定了start_date和end_date,应该返回该时间范围内的所有数据
|
||||
if limit is not None and len(df) > limit:
|
||||
# 检查是否指定了明确的时间范围
|
||||
if start_date and end_date:
|
||||
# 如果指定了时间范围,优先返回完整的时间范围数据
|
||||
if len(df) > 10000: # 防止数据量过大,设置一个合理的上限
|
||||
df = df.tail(10000).reset_index(drop=True)
|
||||
else:
|
||||
# 如果没有指定时间范围,使用默认的limit限制
|
||||
df = df.tail(limit).reset_index(drop=True)
|
||||
elif limit is None and len(df) > 10000:
|
||||
# 即使没有limit限制,也要防止数据量过大影响性能
|
||||
df = df.tail(10000).reset_index(drop=True)
|
||||
|
||||
# 添加技术指标
|
||||
df = self.add_indicators(df)
|
||||
|
||||
# 最终数据验证 - 确保没有NaN值
|
||||
import numpy as np
|
||||
|
||||
# 检查并处理任何剩余的NaN值
|
||||
if df.isnull().any().any():
|
||||
# 对于数值列,用0填充NaN
|
||||
numeric_cols = df.select_dtypes(include=[np.number]).columns
|
||||
for col in numeric_cols:
|
||||
if col in ['volume_ratio']:
|
||||
df[col] = df[col].fillna(1.0)
|
||||
else:
|
||||
df[col] = df[col].fillna(0)
|
||||
|
||||
# 删除仍然包含NaN的行
|
||||
df = df.dropna()
|
||||
|
||||
# 确保所有数值都是有限的
|
||||
for col in df.select_dtypes(include=[np.number]).columns:
|
||||
df[col] = df[col].replace([np.inf, -np.inf], 0 if col != 'volume_ratio' else 1.0)
|
||||
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def add_indicators(self, df):
|
||||
"""添加技术指标"""
|
||||
try:
|
||||
import talib.abstract as ta
|
||||
import numpy as np
|
||||
|
||||
# MACD指标
|
||||
fast = 8
|
||||
slow = 16
|
||||
period = 6
|
||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||
|
||||
df['macd'] = macd['macd'].fillna(0)
|
||||
df['macdsignal'] = macd['macdsignal'].fillna(0)
|
||||
df['macdhist'] = macd['macdhist'].fillna(0)
|
||||
|
||||
# 移动平均线
|
||||
df['ma5'] = ta.MA(df, timeperiod=5).fillna(0)
|
||||
df['ma10'] = ta.MA(df, timeperiod=10).fillna(0)
|
||||
df['ma30'] = ta.EMA(df, timeperiod=30).fillna(0)
|
||||
df['ma250'] = ta.MA(df, timeperiod=250).fillna(0)
|
||||
|
||||
# RSI指标
|
||||
df['rsi'] = ta.RSI(df, timeperiod=14).fillna(0)
|
||||
|
||||
# 成交量指标
|
||||
df['avg_volume'] = df['volume'].rolling(10).mean().fillna(0)
|
||||
df['volume_ratio'] = (df['volume'] / df['avg_volume']).fillna(1.0)
|
||||
|
||||
# 处理Infinity和-Infinity值
|
||||
df['volume_ratio'] = df['volume_ratio'].replace([float('inf'), float('-inf')], 1.0)
|
||||
|
||||
# 确保所有指标列都不包含NaN或无限值
|
||||
indicator_columns = ['macd', 'macdsignal', 'macdhist', 'ma5', 'ma10', 'ma30', 'ma250', 'rsi', 'avg_volume', 'volume_ratio']
|
||||
for col in indicator_columns:
|
||||
if col in df.columns:
|
||||
# 替换NaN、inf、-inf为合理的默认值
|
||||
df[col] = df[col].replace([np.nan, np.inf, -np.inf], 0 if col != 'volume_ratio' else 1.0)
|
||||
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
return df
|
||||
|
||||
def search_stock(self, keyword):
|
||||
"""搜索股票 - 支持代码和名称模糊搜索"""
|
||||
try:
|
||||
if not keyword or len(keyword.strip()) == 0:
|
||||
return []
|
||||
|
||||
keyword = keyword.strip().upper()
|
||||
results = []
|
||||
|
||||
# 从热门股票中搜索
|
||||
popular_stocks = self.get_popular_stocks()
|
||||
for stock in popular_stocks:
|
||||
if (keyword in stock['symbol'] or
|
||||
keyword.lower() in stock['name'].lower() or
|
||||
stock['symbol'].startswith(keyword)):
|
||||
results.append({
|
||||
'symbol': stock['symbol'],
|
||||
'name': stock['name'],
|
||||
'sector': stock.get('sector', ''),
|
||||
'source': '热门股票'
|
||||
})
|
||||
|
||||
# 如果热门股票中找到的结果少于10个,从完整股票列表中搜索
|
||||
if len(results) < 10:
|
||||
try:
|
||||
# 获取完整股票列表进行搜索
|
||||
stock_info = ak.stock_zh_a_spot_em()
|
||||
|
||||
# 搜索前1000只活跃股票
|
||||
for index, row in stock_info.head(1000).iterrows():
|
||||
stock_code = str(row['代码'])
|
||||
stock_name = str(row['名称'])
|
||||
|
||||
# 过滤ST股票
|
||||
if 'ST' in stock_name or '*' in stock_name:
|
||||
continue
|
||||
|
||||
# 检查是否已经在结果中
|
||||
if any(r['symbol'] == stock_code for r in results):
|
||||
continue
|
||||
|
||||
# 搜索匹配
|
||||
if (keyword in stock_code or
|
||||
keyword.lower() in stock_name.lower() or
|
||||
stock_code.startswith(keyword)):
|
||||
results.append({
|
||||
'symbol': stock_code,
|
||||
'name': stock_name,
|
||||
'price': float(row['最新价']) if pd.notna(row['最新价']) else 0.0,
|
||||
'change_pct': float(row['涨跌幅']) if pd.notna(row['涨跌幅']) else 0.0,
|
||||
'source': '全市场搜索'
|
||||
})
|
||||
|
||||
# 限制结果数量
|
||||
if len(results) >= 30:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# 排序:优先显示代码匹配的结果
|
||||
def sort_key(item):
|
||||
if item['symbol'].startswith(keyword):
|
||||
return (0, item['symbol']) # 代码开头匹配优先级最高
|
||||
elif keyword in item['symbol']:
|
||||
return (1, item['symbol']) # 代码包含匹配次之
|
||||
else:
|
||||
return (2, item['symbol']) # 名称匹配最后
|
||||
|
||||
results.sort(key=sort_key)
|
||||
|
||||
# 限制返回结果数量
|
||||
return results[:20]
|
||||
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
def get_stock_by_sector(self, sector=None):
|
||||
"""根据行业获取股票列表"""
|
||||
try:
|
||||
popular_stocks = self.get_popular_stocks()
|
||||
if sector:
|
||||
return [stock for stock in popular_stocks if stock.get('sector', '') == sector]
|
||||
else:
|
||||
# 按行业分组
|
||||
sectors = {}
|
||||
for stock in popular_stocks:
|
||||
sector_name = stock.get('sector', '其他')
|
||||
if sector_name not in sectors:
|
||||
sectors[sector_name] = []
|
||||
sectors[sector_name].append(stock)
|
||||
return sectors
|
||||
except Exception as e:
|
||||
return {} if sector is None else []
|
||||
|
||||
def get_all_sectors(self):
|
||||
"""获取所有行业分类"""
|
||||
try:
|
||||
popular_stocks = self.get_popular_stocks()
|
||||
sectors = set()
|
||||
for stock in popular_stocks:
|
||||
sector = stock.get('sector', '其他')
|
||||
sectors.add(sector)
|
||||
return sorted(list(sectors))
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
def is_trading_day(self, date):
|
||||
"""判断是否为交易日(排除周末和节假日)"""
|
||||
try:
|
||||
# 将日期转换为datetime对象
|
||||
if isinstance(date, str):
|
||||
date = datetime.strptime(date.split()[0], '%Y-%m-%d')
|
||||
elif isinstance(date, pd.Timestamp):
|
||||
date = date.to_pydatetime()
|
||||
|
||||
# 周末不是交易日
|
||||
if date.weekday() >= 5: # 5=周六, 6=周日
|
||||
return False
|
||||
|
||||
# 这里可以进一步添加节假日判断
|
||||
# 目前暂时只过滤周末
|
||||
return True
|
||||
except Exception as e:
|
||||
return True # 默认返回True,避免过度过滤
|
||||
|
||||
def is_trading_time(self, dt):
|
||||
"""判断是否为交易时间"""
|
||||
try:
|
||||
if isinstance(dt, str):
|
||||
dt = pd.to_datetime(dt)
|
||||
|
||||
time_str = dt.strftime('%H:%M')
|
||||
|
||||
# 上午交易时间:09:30-11:30
|
||||
morning_start = self.trading_hours['morning']['start']
|
||||
morning_end = self.trading_hours['morning']['end']
|
||||
|
||||
# 下午交易时间:13:00-15:00
|
||||
afternoon_start = self.trading_hours['afternoon']['start']
|
||||
afternoon_end = self.trading_hours['afternoon']['end']
|
||||
|
||||
return ((morning_start <= time_str <= morning_end) or
|
||||
(afternoon_start <= time_str <= afternoon_end))
|
||||
except Exception as e:
|
||||
return True # 默认返回True,避免过度过滤
|
||||
|
||||
def adjust_timestamp_for_trading_hours(self, df, timeframe):
|
||||
"""根据A股交易时间调整时间戳"""
|
||||
try:
|
||||
if df is None or len(df) == 0:
|
||||
return df
|
||||
|
||||
# 确保date列是datetime类型
|
||||
if 'date' in df.columns:
|
||||
df['date'] = pd.to_datetime(df['date'])
|
||||
|
||||
# 对于日线数据,设置为收盘时间(15:00)
|
||||
if timeframe == '1d':
|
||||
df['date'] = df['date'].dt.normalize() + pd.Timedelta(hours=15)
|
||||
|
||||
# 对于分钟级数据,过滤非交易时间的数据
|
||||
elif timeframe in ['1m', '5m', '15m', '30m', '1h']:
|
||||
# 过滤交易日
|
||||
df = df[df['date'].apply(self.is_trading_day)]
|
||||
|
||||
# 过滤交易时间(只在有足够数据时进行)
|
||||
if len(df) > 10: # 避免过度过滤导致数据不足
|
||||
df = df[df['date'].apply(self.is_trading_time)]
|
||||
|
||||
# 重新计算时间戳
|
||||
if 'date' in df.columns:
|
||||
# 将时间转换为上海时区
|
||||
df['date'] = df['date'].dt.tz_localize('Asia/Shanghai', ambiguous='infer', nonexistent='shift_forward')
|
||||
# 转换为毫秒时间戳
|
||||
df['timestamp'] = df['date'].astype('int64') // 10**6
|
||||
|
||||
return df.reset_index(drop=True)
|
||||
|
||||
except Exception as e:
|
||||
return df
|
||||
|
||||
def get_trading_calendar(self, start_date, end_date):
|
||||
"""获取交易日历(简化版本)"""
|
||||
try:
|
||||
# 使用akshare获取交易日历
|
||||
trading_calendar = ak.tool_trade_date_hist_sina()
|
||||
|
||||
# 过滤指定日期范围
|
||||
start_dt = pd.to_datetime(start_date)
|
||||
end_dt = pd.to_datetime(end_date)
|
||||
|
||||
trading_days = []
|
||||
for _, row in trading_calendar.iterrows():
|
||||
trade_date = pd.to_datetime(row['trade_date'])
|
||||
if start_dt <= trade_date <= end_dt:
|
||||
trading_days.append(trade_date.strftime('%Y-%m-%d'))
|
||||
|
||||
return trading_days
|
||||
except Exception as e:
|
||||
# 如果获取失败,生成简单的工作日列表(排除周末)
|
||||
trading_days = []
|
||||
current = pd.to_datetime(start_date)
|
||||
end = pd.to_datetime(end_date)
|
||||
|
||||
while current <= end:
|
||||
if current.weekday() < 5: # 周一到周五
|
||||
trading_days.append(current.strftime('%Y-%m-%d'))
|
||||
current += timedelta(days=1)
|
||||
|
||||
return trading_days
|
||||
|
||||
def fill_trading_gaps(self, df, timeframe):
|
||||
"""填补A股交易时间间隙,确保图表连续性"""
|
||||
try:
|
||||
if df is None or len(df) == 0:
|
||||
return df
|
||||
|
||||
# 对于日线数据,不需要填补间隙,因为本来就是每日一个数据点
|
||||
if timeframe == '1d':
|
||||
return df
|
||||
|
||||
# 对于分钟级数据,创建完整的交易时间序列
|
||||
if timeframe in ['1m', '5m', '15m', '30m', '1h']:
|
||||
# 获取数据的开始和结束时间
|
||||
start_date = df['date'].min().date()
|
||||
end_date = df['date'].max().date()
|
||||
|
||||
# 创建完整的交易时间序列
|
||||
complete_times = []
|
||||
current_date = start_date
|
||||
|
||||
# 获取时间间隔(分钟)
|
||||
freq_map = {'1m': 1, '5m': 5, '15m': 15, '30m': 30, '1h': 60}
|
||||
freq_minutes = freq_map.get(timeframe, 5)
|
||||
|
||||
while current_date <= end_date:
|
||||
# 只处理交易日
|
||||
if self.is_trading_day(current_date):
|
||||
# 上午交易时间 - 使用datetime.time而不是pd.Time
|
||||
morning_start = pd.Timestamp.combine(current_date, time(9, 30))
|
||||
morning_end = pd.Timestamp.combine(current_date, time(11, 30))
|
||||
|
||||
# 下午交易时间
|
||||
afternoon_start = pd.Timestamp.combine(current_date, time(13, 0))
|
||||
afternoon_end = pd.Timestamp.combine(current_date, time(15, 0))
|
||||
|
||||
# 生成上午时间序列
|
||||
current_time = morning_start
|
||||
while current_time <= morning_end:
|
||||
complete_times.append(current_time)
|
||||
current_time += pd.Timedelta(minutes=freq_minutes)
|
||||
|
||||
# 生成下午时间序列
|
||||
current_time = afternoon_start
|
||||
while current_time <= afternoon_end:
|
||||
complete_times.append(current_time)
|
||||
current_time += pd.Timedelta(minutes=freq_minutes)
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# 创建完整时间序列的DataFrame
|
||||
if complete_times:
|
||||
complete_df = pd.DataFrame({'date': complete_times})
|
||||
complete_df['date'] = complete_df['date'].dt.tz_localize('Asia/Shanghai')
|
||||
complete_df['timestamp'] = complete_df['date'].astype('int64') // 10**6
|
||||
|
||||
# 将原始数据合并到完整时间序列
|
||||
# 使用时间戳进行合并,避免时区问题
|
||||
df_merged = pd.merge(complete_df, df, on='timestamp', how='left', suffixes=('', '_orig'))
|
||||
|
||||
# 保持原有date列
|
||||
df_merged['date'] = df_merged['date']
|
||||
|
||||
# 对于缺失的OHLCV数据,使用前向填充
|
||||
price_cols = ['open', 'high', 'low', 'close']
|
||||
for col in price_cols:
|
||||
if col in df_merged.columns:
|
||||
df_merged[col] = df_merged[col].ffill()
|
||||
|
||||
# 成交量缺失时设为0
|
||||
if 'volume' in df_merged.columns:
|
||||
df_merged['volume'] = df_merged['volume'].fillna(0)
|
||||
|
||||
# 删除辅助列
|
||||
cols_to_drop = [col for col in df_merged.columns if col.endswith('_orig')]
|
||||
df_merged = df_merged.drop(columns=cols_to_drop)
|
||||
|
||||
return df_merged
|
||||
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
return df
|
||||
|
||||
def clean_a_stock_data(self, df, timeframe):
|
||||
"""清理A股数据,处理异常值和时间问题"""
|
||||
try:
|
||||
if df is None or len(df) == 0:
|
||||
return df
|
||||
|
||||
import numpy as np
|
||||
|
||||
# 首先删除所有包含NaN的行
|
||||
df = df.dropna()
|
||||
|
||||
# 删除价格异常的数据
|
||||
price_cols = ['open', 'high', 'low', 'close']
|
||||
for col in price_cols:
|
||||
if col in df.columns:
|
||||
# 删除价格为0、负数、NaN、inf的记录
|
||||
df = df[df[col] > 0]
|
||||
df = df[np.isfinite(df[col])]
|
||||
|
||||
# 检查OHLC逻辑合理性
|
||||
if all(col in df.columns for col in price_cols):
|
||||
# high应该是最高价
|
||||
df = df[df['high'] >= df['open']]
|
||||
df = df[df['high'] >= df['close']]
|
||||
# low应该是最低价
|
||||
df = df[df['low'] <= df['open']]
|
||||
df = df[df['low'] <= df['close']]
|
||||
# high应该大于等于low
|
||||
df = df[df['high'] >= df['low']]
|
||||
|
||||
# 删除成交量异常的数据
|
||||
if 'volume' in df.columns:
|
||||
# 删除成交量为负数、NaN、inf的记录
|
||||
df = df[df['volume'] >= 0]
|
||||
df = df[np.isfinite(df['volume'])]
|
||||
|
||||
# 确保所有数值列都不包含NaN或无限值
|
||||
numeric_cols = df.select_dtypes(include=[np.number]).columns
|
||||
for col in numeric_cols:
|
||||
# 替换NaN、inf、-inf为0(除了价格列,价格列的异常值已经被过滤掉了)
|
||||
if col not in price_cols:
|
||||
df[col] = df[col].replace([np.nan, np.inf, -np.inf], 0)
|
||||
|
||||
# 确保时间序列连续性(仅对分钟级数据)
|
||||
if timeframe in ['1m', '5m', '15m', '30m', '1h']:
|
||||
df = self.fill_trading_gaps(df, timeframe)
|
||||
|
||||
# 最后再次检查并清理任何剩余的NaN值
|
||||
df = df.dropna()
|
||||
|
||||
return df.reset_index(drop=True)
|
||||
|
||||
except Exception as e:
|
||||
return df
|
||||
"""兼容 shim。"""
|
||||
from services.cn_stock import * # noqa: F403
|
||||
from services.cn_stock import ChinaStockData # noqa: F401
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Web 运行时配置(环境变量优先,去掉硬编码代理)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
DATA_SERVICE_URL = os.environ.get(
|
||||
"DATA_SERVICE_URL",
|
||||
os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"),
|
||||
)
|
||||
ASHARE_DP_URL = os.environ.get("ASHARE_DP_URL", "http://103.179.242.166:8000")
|
||||
|
||||
# HTTP 代理:未设置则不走代理;可设 HTTP_PROXY/HTTPS_PROXY 或 CHAN_HTTP_PROXY
|
||||
_CHAN_PROXY = os.environ.get("CHAN_HTTP_PROXY") or os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy")
|
||||
_HTTPS_PROXY = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") or _CHAN_PROXY
|
||||
|
||||
def ccxt_proxies() -> dict | None:
|
||||
if not _CHAN_PROXY and not _HTTPS_PROXY:
|
||||
return None
|
||||
return {
|
||||
"http": _CHAN_PROXY or _HTTPS_PROXY,
|
||||
"https": _HTTPS_PROXY or _CHAN_PROXY,
|
||||
}
|
||||
|
||||
MACD_FACTOR = int(os.environ.get("MACD_FACTOR", "1"))
|
||||
MACD_SMOOTH = int(os.environ.get("MACD_SMOOTH", "1"))
|
||||
MACD_FAST = 12 * MACD_FACTOR
|
||||
MACD_SLOW = 26 * MACD_FACTOR
|
||||
MACD_SIGNAL = 9 * MACD_SMOOTH
|
||||
|
||||
FLASK_HOST = os.environ.get("FLASK_HOST", "0.0.0.0")
|
||||
FLASK_PORT = int(os.environ.get("FLASK_PORT", "8128"))
|
||||
@@ -0,0 +1,10 @@
|
||||
"""缠论分析服务。"""
|
||||
from services.runtime import ( # noqa: F401
|
||||
add_indicators,
|
||||
calculate_macd,
|
||||
analyze_chan,
|
||||
classify_trend_stage,
|
||||
macd_fast_period,
|
||||
macd_slow_period,
|
||||
macd_signal_period,
|
||||
)
|
||||
@@ -0,0 +1,966 @@
|
||||
import os
|
||||
import akshare as ak
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta, time
|
||||
import time as time_module
|
||||
import traceback
|
||||
from pytz import timezone
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 与 A-Share Data Platform REST 文档一致的周期(分钟线依赖服务端积累,无数据时会回退 AKShare)
|
||||
ASHARE_REST_TIMEFRAMES = frozenset({'1m', '5m', '15m', '30m', '1h', '2h', '1d', '1w', '1M'})
|
||||
|
||||
|
||||
class ChinaStockData:
|
||||
"""A股数据获取类"""
|
||||
|
||||
def __init__(self):
|
||||
self.tz = timezone('Asia/Shanghai')
|
||||
# A股交易时间配置
|
||||
self.trading_hours = {
|
||||
'morning': {'start': '09:30', 'end': '11:30'},
|
||||
'afternoon': {'start': '13:00', 'end': '15:00'}
|
||||
}
|
||||
# 例: http://103.179.242.166:8000 — 设 ASHARE_DP_URL= 空字符串可禁用,仅用 AKShare
|
||||
_base = os.environ.get('ASHARE_DP_URL', 'http://103.179.242.166:8000')
|
||||
self.ashare_dp_base = _base.rstrip('/') if (_base or '').strip() else ''
|
||||
# 全量股票列表内存缓存(秒),默认 1 小时
|
||||
try:
|
||||
self.stock_list_cache_ttl = int(os.environ.get('ASHARE_STOCK_LIST_CACHE_SEC', '3600'))
|
||||
except ValueError:
|
||||
self.stock_list_cache_ttl = 3600
|
||||
self._stock_list_cache = None
|
||||
self._stock_list_cache_expires = 0.0
|
||||
|
||||
def _get_stock_list_akshare(self):
|
||||
"""通过 AKShare 获取 A 股列表(约 2000 条非 ST,作备用)。"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
try:
|
||||
original_timeout = getattr(requests, 'timeout', None)
|
||||
requests.timeout = 10
|
||||
|
||||
stock_info = ak.stock_zh_a_spot_em()
|
||||
|
||||
if original_timeout:
|
||||
requests.timeout = original_timeout
|
||||
else:
|
||||
delattr(requests, 'timeout')
|
||||
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if stock_info is None or len(stock_info) == 0:
|
||||
return []
|
||||
|
||||
stock_list = []
|
||||
for index, row in stock_info.head(2000).iterrows():
|
||||
try:
|
||||
stock_name = str(row['名称'])
|
||||
if 'ST' not in stock_name and '*' not in stock_name:
|
||||
stock_list.append({
|
||||
'symbol': row['代码'],
|
||||
'name': row['名称'],
|
||||
'price': float(row['最新价']) if pd.notna(row['最新价']) else 0.0,
|
||||
'change_pct': float(row['涨跌幅']) if pd.notna(row['涨跌幅']) else 0.0,
|
||||
'volume': float(row['成交量']) if pd.notna(row['成交量']) else 0.0,
|
||||
'amount': float(row['成交额']) if pd.notna(row['成交额']) else 0.0
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
stock_list.sort(key=lambda x: x['amount'], reverse=True)
|
||||
return stock_list
|
||||
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _fetch_all_stocks_ashare_dp(self):
|
||||
"""分页拉取 A-Share Data Platform /api/v1/stocks 全市场标的。"""
|
||||
import requests
|
||||
|
||||
page_size = 1000
|
||||
offset = 0
|
||||
all_rows = []
|
||||
reported_total = None
|
||||
url = f'{self.ashare_dp_base}/api/v1/stocks'
|
||||
while True:
|
||||
resp = requests.get(
|
||||
url,
|
||||
params={'limit': page_size, 'offset': offset},
|
||||
timeout=45,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
items = payload.get('items') or []
|
||||
if reported_total is None:
|
||||
reported_total = int(payload.get('total') or 0)
|
||||
all_rows.extend(items)
|
||||
if len(items) == 0:
|
||||
break
|
||||
if len(items) < page_size:
|
||||
break
|
||||
offset += page_size
|
||||
if reported_total and offset >= reported_total:
|
||||
break
|
||||
if not all_rows:
|
||||
return []
|
||||
out = []
|
||||
for row in all_rows:
|
||||
sym = row.get('symbol')
|
||||
if not sym and row.get('ts_code'):
|
||||
sym = str(row['ts_code']).split('.')[0]
|
||||
if not sym:
|
||||
continue
|
||||
name = row.get('name') or ''
|
||||
out.append({
|
||||
'symbol': str(sym).strip(),
|
||||
'name': str(name).strip(),
|
||||
'ts_code': row.get('ts_code'),
|
||||
'price': 0.0,
|
||||
'change_pct': 0.0,
|
||||
'volume': 0.0,
|
||||
'amount': 0.0,
|
||||
})
|
||||
out.sort(key=lambda x: x['symbol'])
|
||||
return out
|
||||
|
||||
def get_stock_list(self, use_cache=True):
|
||||
"""获取 A 股股票列表:优先全量 REST(约 5500+),失败则 AKShare。"""
|
||||
now = time_module.time()
|
||||
if use_cache and self._stock_list_cache is not None and now < self._stock_list_cache_expires:
|
||||
return list(self._stock_list_cache)
|
||||
|
||||
if self.ashare_dp_base:
|
||||
try:
|
||||
dp_list = self._fetch_all_stocks_ashare_dp()
|
||||
if dp_list:
|
||||
self._stock_list_cache = dp_list
|
||||
self._stock_list_cache_expires = now + self.stock_list_cache_ttl
|
||||
return list(dp_list)
|
||||
except Exception as exc:
|
||||
logger.warning('A股列表从数据服务拉取失败,回退 AKShare: %s', exc)
|
||||
|
||||
ak_list = self._get_stock_list_akshare()
|
||||
if ak_list:
|
||||
self._stock_list_cache = ak_list
|
||||
self._stock_list_cache_expires = now + min(self.stock_list_cache_ttl, 300)
|
||||
return ak_list or []
|
||||
|
||||
def get_available_kline_freqs(self):
|
||||
"""
|
||||
A-Share Data Platform 支持的 K 线周期列表(原始顺序不保证,由上层按粒度排序)。
|
||||
文档: GET /api/v1/klines/available-freqs
|
||||
"""
|
||||
import requests
|
||||
|
||||
fallback = ['1m', '5m', '15m', '30m', '1h', '2h', '1d', '1w', '1M']
|
||||
if not self.ashare_dp_base:
|
||||
return list(fallback)
|
||||
try:
|
||||
url = f'{self.ashare_dp_base}/api/v1/klines/available-freqs'
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
freqs = data.get('frequencies') or []
|
||||
return list(freqs) if freqs else list(fallback)
|
||||
except Exception as exc:
|
||||
logger.warning('获取 A 股可用 K 线周期失败: %s', exc)
|
||||
return list(fallback)
|
||||
|
||||
def get_popular_stocks(self):
|
||||
"""获取热门A股股票代码列表 - 扩展版本,按行业分类"""
|
||||
return [
|
||||
# 包装引印刷
|
||||
{'symbol': '002836', 'name': '新宏泽', 'sector': '包装印刷'},
|
||||
# 银行股
|
||||
{'symbol': '600036', 'name': '招商银行', 'sector': '银行'},
|
||||
{'symbol': '000001', 'name': '平安银行', 'sector': '银行'},
|
||||
{'symbol': '600000', 'name': '浦发银行', 'sector': '银行'},
|
||||
{'symbol': '002142', 'name': '宁波银行', 'sector': '银行'},
|
||||
{'symbol': '600016', 'name': '民生银行', 'sector': '银行'},
|
||||
{'symbol': '601288', 'name': '农业银行', 'sector': '银行'},
|
||||
{'symbol': '601398', 'name': '工商银行', 'sector': '银行'},
|
||||
{'symbol': '601328', 'name': '交通银行', 'sector': '银行'},
|
||||
|
||||
# 白酒股
|
||||
{'symbol': '600519', 'name': '贵州茅台', 'sector': '白酒'},
|
||||
{'symbol': '000858', 'name': '五粮液', 'sector': '白酒'},
|
||||
{'symbol': '002304', 'name': '洋河股份', 'sector': '白酒'},
|
||||
{'symbol': '000596', 'name': '古井贡酒', 'sector': '白酒'},
|
||||
{'symbol': '603369', 'name': '今世缘', 'sector': '白酒'},
|
||||
{'symbol': '000799', 'name': '酒鬼酒', 'sector': '白酒'},
|
||||
{'symbol': '600809', 'name': '山西汾酒', 'sector': '白酒'},
|
||||
|
||||
# 科技股
|
||||
{'symbol': '002415', 'name': '海康威视', 'sector': '科技'},
|
||||
{'symbol': '000063', 'name': '中兴通讯', 'sector': '科技'},
|
||||
{'symbol': '002475', 'name': '立讯精密', 'sector': '科技'},
|
||||
{'symbol': '300059', 'name': '东方财富', 'sector': '科技'},
|
||||
{'symbol': '000725', 'name': '京东方A', 'sector': '科技'},
|
||||
{'symbol': '002230', 'name': '科大讯飞', 'sector': '科技'},
|
||||
{'symbol': '300433', 'name': '蓝思科技', 'sector': '科技'},
|
||||
{'symbol': '002236', 'name': '大华股份', 'sector': '科技'},
|
||||
|
||||
# 新能源
|
||||
{'symbol': '300750', 'name': '宁德时代', 'sector': '新能源'},
|
||||
{'symbol': '002594', 'name': '比亚迪', 'sector': '新能源'},
|
||||
{'symbol': '300274', 'name': '阳光电源', 'sector': '新能源'},
|
||||
{'symbol': '002460', 'name': '赣锋锂业', 'sector': '新能源'},
|
||||
{'symbol': '300014', 'name': '亿纬锂能', 'sector': '新能源'},
|
||||
{'symbol': '600884', 'name': '杉杉股份', 'sector': '新能源'},
|
||||
{'symbol': '002812', 'name': '恩捷股份', 'sector': '新能源'},
|
||||
|
||||
# 房地产
|
||||
{'symbol': '000002', 'name': '万科A', 'sector': '房地产'},
|
||||
{'symbol': '000858', 'name': '五粮液', 'sector': '房地产'},
|
||||
{'symbol': '600048', 'name': '保利发展', 'sector': '房地产'},
|
||||
{'symbol': '001979', 'name': '招商蛇口', 'sector': '房地产'},
|
||||
{'symbol': '600606', 'name': '绿地控股', 'sector': '房地产'},
|
||||
|
||||
# 消费股
|
||||
{'symbol': '600887', 'name': '伊利股份', 'sector': '消费'},
|
||||
{'symbol': '000568', 'name': '泸州老窖', 'sector': '消费'},
|
||||
{'symbol': '600600', 'name': '青岛啤酒', 'sector': '消费'},
|
||||
{'symbol': '000895', 'name': '双汇发展', 'sector': '消费'},
|
||||
{'symbol': '002304', 'name': '洋河股份', 'sector': '消费'},
|
||||
{'symbol': '600779', 'name': '水井坊', 'sector': '消费'},
|
||||
|
||||
# 医药股
|
||||
{'symbol': '600196', 'name': '复星医药', 'sector': '医药'},
|
||||
{'symbol': '000661', 'name': '长春高新', 'sector': '医药'},
|
||||
{'symbol': '300015', 'name': '爱尔眼科', 'sector': '医药'},
|
||||
{'symbol': '002821', 'name': '凯莱英', 'sector': '医药'},
|
||||
{'symbol': '300760', 'name': '迈瑞医疗', 'sector': '医药'},
|
||||
{'symbol': '600276', 'name': '恒瑞医药', 'sector': '医药'},
|
||||
|
||||
# 证券股
|
||||
{'symbol': '000776', 'name': '广发证券', 'sector': '证券'},
|
||||
{'symbol': '600030', 'name': '中信证券', 'sector': '证券'},
|
||||
{'symbol': '000166', 'name': '申万宏源', 'sector': '证券'},
|
||||
{'symbol': '601688', 'name': '华泰证券', 'sector': '证券'},
|
||||
{'symbol': '600837', 'name': '海通证券', 'sector': '证券'},
|
||||
|
||||
# 化工股
|
||||
{'symbol': '600309', 'name': '万华化学', 'sector': '化工'},
|
||||
{'symbol': '002352', 'name': '顺丰控股', 'sector': '化工'},
|
||||
{'symbol': '600346', 'name': '恒力石化', 'sector': '化工'},
|
||||
{'symbol': '000792', 'name': '盐湖股份', 'sector': '化工'},
|
||||
|
||||
# 汽车股
|
||||
{'symbol': '600104', 'name': '上汽集团', 'sector': '汽车'},
|
||||
{'symbol': '000625', 'name': '长安汽车', 'sector': '汽车'},
|
||||
{'symbol': '601633', 'name': '长城汽车', 'sector': '汽车'},
|
||||
{'symbol': '002049', 'name': '紫光国微', 'sector': '汽车'},
|
||||
|
||||
# 军工股
|
||||
{'symbol': '002179', 'name': '中航光电', 'sector': '军工'},
|
||||
{'symbol': '600893', 'name': '航发动力', 'sector': '军工'},
|
||||
{'symbol': '000768', 'name': '中航飞机', 'sector': '军工'},
|
||||
|
||||
# 基建股
|
||||
{'symbol': '601186', 'name': '中国铁建', 'sector': '基建'},
|
||||
{'symbol': '601390', 'name': '中国中铁', 'sector': '基建'},
|
||||
{'symbol': '000001', 'name': '平安银行', 'sector': '基建'},
|
||||
|
||||
# 煤炭股
|
||||
{'symbol': '601225', 'name': '陕西煤业', 'sector': '煤炭'},
|
||||
{'symbol': '600188', 'name': '兖矿能源', 'sector': '煤炭'},
|
||||
{'symbol': '601898', 'name': '中煤能源', 'sector': '煤炭'},
|
||||
|
||||
# 钢铁股
|
||||
{'symbol': '000717', 'name': '韶钢松山', 'sector': '钢铁'},
|
||||
{'symbol': '600019', 'name': '宝钢股份', 'sector': '钢铁'},
|
||||
{'symbol': '000708', 'name': '中信特钢', 'sector': '钢铁'},
|
||||
]
|
||||
|
||||
def timeframe_to_period(self, timeframe):
|
||||
"""将时间周期转换为akshare的period参数"""
|
||||
mapping = {
|
||||
'1m': '1', # 1分钟
|
||||
'5m': '5', # 5分钟
|
||||
'15m': '15', # 15分钟
|
||||
'30m': '30', # 30分钟
|
||||
'1h': '60', # 60分钟
|
||||
'1d': 'daily', # 日线
|
||||
'1w': 'weekly',# 周线
|
||||
'1M': 'monthly'# 月线
|
||||
}
|
||||
return mapping.get(timeframe, 'daily')
|
||||
|
||||
@staticmethod
|
||||
def symbol_to_ts_code(symbol):
|
||||
"""六位代码或已是 ts_code(000001.SZ)→ 交易所后缀。"""
|
||||
if symbol is None:
|
||||
return ''
|
||||
s = str(symbol).strip().upper()
|
||||
if '.' in s and s.count('.') == 1:
|
||||
return s
|
||||
if len(s) != 6 or not s.isdigit():
|
||||
return s
|
||||
if s.startswith('6'):
|
||||
return f'{s}.SH'
|
||||
if s.startswith(('0', '3')):
|
||||
return f'{s}.SZ'
|
||||
if s.startswith('920'):
|
||||
return f'{s}.BJ'
|
||||
if s.startswith(('8', '4')):
|
||||
return f'{s}.BJ'
|
||||
return f'{s}.SZ'
|
||||
|
||||
@staticmethod
|
||||
def _ymd_compact_to_api_date(ymd_compact):
|
||||
"""YYYYMMDD → YYYY-MM-DD"""
|
||||
if not ymd_compact or len(ymd_compact) != 8:
|
||||
return None
|
||||
return f'{ymd_compact[:4]}-{ymd_compact[4:6]}-{ymd_compact[6:8]}'
|
||||
|
||||
def get_kl_data_from_ashare_dp(self, symbol, timeframe, start_date, end_date, limit):
|
||||
"""
|
||||
从 A-Share Data Platform(/api/v1/klines/{freq})拉取 K 线。
|
||||
start_date / end_date 为 YYYYMMDD 字符串。
|
||||
"""
|
||||
if not self.ashare_dp_base or timeframe not in ASHARE_REST_TIMEFRAMES:
|
||||
return None
|
||||
import requests
|
||||
|
||||
ts_code = self.symbol_to_ts_code(symbol)
|
||||
if not ts_code or '.' not in ts_code:
|
||||
return None
|
||||
start_api = self._ymd_compact_to_api_date(start_date)
|
||||
end_api = self._ymd_compact_to_api_date(end_date)
|
||||
if not start_api or not end_api:
|
||||
return None
|
||||
api_limit = 10000
|
||||
if limit is not None:
|
||||
try:
|
||||
api_limit = min(int(limit), 10000)
|
||||
except (TypeError, ValueError):
|
||||
api_limit = 10000
|
||||
url = f'{self.ashare_dp_base}/api/v1/klines/{timeframe}'
|
||||
params = {
|
||||
'ts_code': ts_code,
|
||||
'start_date': start_api,
|
||||
'end_date': end_api,
|
||||
'limit': api_limit,
|
||||
}
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=20)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except Exception as exc:
|
||||
logger.debug('A股数据服务 K 线请求失败: %s', exc)
|
||||
return None
|
||||
items = payload.get('items') or payload.get('data') or []
|
||||
if not items:
|
||||
return None
|
||||
rows = []
|
||||
for row in items:
|
||||
t = row.get('trade_time') or row.get('trade_date')
|
||||
if not t:
|
||||
continue
|
||||
rows.append({
|
||||
'date': t,
|
||||
'open': row.get('open'),
|
||||
'high': row.get('high'),
|
||||
'low': row.get('low'),
|
||||
'close': row.get('close'),
|
||||
'volume': row.get('volume'),
|
||||
})
|
||||
if not rows:
|
||||
return None
|
||||
df = pd.DataFrame(rows)
|
||||
df['date'] = pd.to_datetime(df['date'])
|
||||
for col in ('open', 'high', 'low', 'close', 'volume'):
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_numeric(df[col], errors='coerce')
|
||||
df = df.dropna(subset=['open', 'high', 'low', 'close'])
|
||||
df = df.sort_values('date').reset_index(drop=True)
|
||||
df = self.adjust_timestamp_for_trading_hours(df, timeframe)
|
||||
df = self.clean_a_stock_data(df, timeframe)
|
||||
if df is None or len(df) == 0:
|
||||
return None
|
||||
if limit is not None:
|
||||
try:
|
||||
lim = int(limit)
|
||||
if len(df) > lim:
|
||||
df = df.tail(lim).reset_index(drop=True)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
elif len(df) > 10000:
|
||||
df = df.tail(10000).reset_index(drop=True)
|
||||
df = self.add_indicators(df)
|
||||
return df
|
||||
|
||||
def get_kl_data(self, symbol, timeframe='1d', start_date=None, end_date=None, limit=10000):
|
||||
"""
|
||||
获取A股K线数据 - 支持分批次获取突破单次限制
|
||||
:param symbol: 股票代码,如 '000001'
|
||||
:param timeframe: 时间周期,如 '1d', '1h', '5m'
|
||||
:param start_date: 开始日期,格式 'YYYY-MM-DD'
|
||||
:param end_date: 结束日期,格式 'YYYY-MM-DD'
|
||||
:param limit: 数据条数限制
|
||||
:return: DataFrame
|
||||
"""
|
||||
try:
|
||||
period = self.timeframe_to_period(timeframe)
|
||||
|
||||
# 处理时间参数
|
||||
if start_date is None:
|
||||
# 默认获取最近一年的数据
|
||||
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y%m%d')
|
||||
else:
|
||||
# 将 YYYY-MM-DD 格式转换为 YYYYMMDD
|
||||
if '-' in start_date:
|
||||
start_date = start_date.replace('-', '')
|
||||
|
||||
if end_date is None:
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
else:
|
||||
if '-' in end_date:
|
||||
end_date = end_date.replace('-', '')
|
||||
|
||||
if self.ashare_dp_base:
|
||||
df_dp = self.get_kl_data_from_ashare_dp(
|
||||
symbol, timeframe, start_date, end_date, limit
|
||||
)
|
||||
if df_dp is not None and len(df_dp) > 0:
|
||||
return df_dp
|
||||
|
||||
# 分批次获取数据以突破单次限制
|
||||
all_data = []
|
||||
current_start = start_date
|
||||
|
||||
# 计算时间间隔(根据时间周期调整批次大小)
|
||||
if period in ['1', '5', '15', '30']:
|
||||
# 分钟级数据,每次获取7天
|
||||
batch_days = 7
|
||||
elif period == '60':
|
||||
# 小时级数据,每次获取30天
|
||||
batch_days = 30
|
||||
else:
|
||||
# 日线及以上,每次获取365天
|
||||
batch_days = 365
|
||||
|
||||
max_iterations = 20 # 最大迭代次数,防止无限循环
|
||||
iteration_count = 0
|
||||
|
||||
while current_start <= end_date and iteration_count < max_iterations:
|
||||
iteration_count += 1
|
||||
|
||||
# 计算当前批次的结束时间
|
||||
current_start_dt = datetime.strptime(current_start, '%Y%m%d')
|
||||
current_end_dt = current_start_dt + timedelta(days=batch_days)
|
||||
current_end = min(current_end_dt.strftime('%Y%m%d'), end_date)
|
||||
|
||||
pass
|
||||
|
||||
try:
|
||||
# 根据时间周期选择不同的API
|
||||
df_batch = None
|
||||
if period in ['1', '5', '15', '30', '60']:
|
||||
# 分钟级数据
|
||||
df_batch = ak.stock_zh_a_hist_min_em(symbol=symbol, period=period,
|
||||
start_date=current_start, end_date=current_end)
|
||||
if df_batch is not None and len(df_batch) > 0:
|
||||
# 重命名列
|
||||
df_batch = df_batch.rename(columns={
|
||||
'时间': 'date',
|
||||
'开盘': 'open',
|
||||
'收盘': 'close',
|
||||
'最高': 'high',
|
||||
'最低': 'low',
|
||||
'成交量': 'volume'
|
||||
})
|
||||
else:
|
||||
# 日线、周线、月线数据
|
||||
df_batch = ak.stock_zh_a_hist(symbol=symbol, period=period,
|
||||
start_date=current_start, end_date=current_end)
|
||||
if df_batch is not None and len(df_batch) > 0:
|
||||
# 重命名列
|
||||
df_batch = df_batch.rename(columns={
|
||||
'日期': 'date',
|
||||
'开盘': 'open',
|
||||
'收盘': 'close',
|
||||
'最高': 'high',
|
||||
'最低': 'low',
|
||||
'成交量': 'volume'
|
||||
})
|
||||
|
||||
if df_batch is not None and len(df_batch) > 0:
|
||||
# 转换时间格式
|
||||
df_batch['date'] = pd.to_datetime(df_batch['date'])
|
||||
|
||||
# 根据A股交易时间调整时间戳
|
||||
df_batch = self.adjust_timestamp_for_trading_hours(df_batch, timeframe)
|
||||
|
||||
all_data.append(df_batch)
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
# 继续下一个批次
|
||||
pass
|
||||
|
||||
# 更新下一批次的开始时间
|
||||
current_start = (current_end_dt + timedelta(days=1)).strftime('%Y%m%d')
|
||||
|
||||
# 防止API请求过于频繁
|
||||
time_module.sleep(0.5)
|
||||
|
||||
# 合并所有批次的数据
|
||||
if not all_data:
|
||||
return None
|
||||
|
||||
# 合并DataFrame
|
||||
df = pd.concat(all_data, ignore_index=True)
|
||||
|
||||
# 数据清洗和格式化
|
||||
df = df.dropna() # 删除空值
|
||||
df = df.drop_duplicates(subset=['date']) # 删除重复数据
|
||||
df = df.sort_values('date').reset_index(drop=True) # 按时间排序
|
||||
|
||||
# A股特有的数据清理和时间处理
|
||||
df = self.clean_a_stock_data(df, timeframe)
|
||||
|
||||
# 限制数据条数 - 只有在没有指定明确时间范围时才应用
|
||||
# 如果用户指定了start_date和end_date,应该返回该时间范围内的所有数据
|
||||
if limit is not None and len(df) > limit:
|
||||
# 检查是否指定了明确的时间范围
|
||||
if start_date and end_date:
|
||||
# 如果指定了时间范围,优先返回完整的时间范围数据
|
||||
if len(df) > 10000: # 防止数据量过大,设置一个合理的上限
|
||||
df = df.tail(10000).reset_index(drop=True)
|
||||
else:
|
||||
# 如果没有指定时间范围,使用默认的limit限制
|
||||
df = df.tail(limit).reset_index(drop=True)
|
||||
elif limit is None and len(df) > 10000:
|
||||
# 即使没有limit限制,也要防止数据量过大影响性能
|
||||
df = df.tail(10000).reset_index(drop=True)
|
||||
|
||||
# 添加技术指标
|
||||
df = self.add_indicators(df)
|
||||
|
||||
# 最终数据验证 - 确保没有NaN值
|
||||
import numpy as np
|
||||
|
||||
# 检查并处理任何剩余的NaN值
|
||||
if df.isnull().any().any():
|
||||
# 对于数值列,用0填充NaN
|
||||
numeric_cols = df.select_dtypes(include=[np.number]).columns
|
||||
for col in numeric_cols:
|
||||
if col in ['volume_ratio']:
|
||||
df[col] = df[col].fillna(1.0)
|
||||
else:
|
||||
df[col] = df[col].fillna(0)
|
||||
|
||||
# 删除仍然包含NaN的行
|
||||
df = df.dropna()
|
||||
|
||||
# 确保所有数值都是有限的
|
||||
for col in df.select_dtypes(include=[np.number]).columns:
|
||||
df[col] = df[col].replace([np.inf, -np.inf], 0 if col != 'volume_ratio' else 1.0)
|
||||
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def add_indicators(self, df):
|
||||
"""添加技术指标"""
|
||||
try:
|
||||
import talib.abstract as ta
|
||||
import numpy as np
|
||||
|
||||
# MACD指标
|
||||
fast = 8
|
||||
slow = 16
|
||||
period = 6
|
||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||
|
||||
df['macd'] = macd['macd'].fillna(0)
|
||||
df['macdsignal'] = macd['macdsignal'].fillna(0)
|
||||
df['macdhist'] = macd['macdhist'].fillna(0)
|
||||
|
||||
# 移动平均线
|
||||
df['ma5'] = ta.MA(df, timeperiod=5).fillna(0)
|
||||
df['ma10'] = ta.MA(df, timeperiod=10).fillna(0)
|
||||
df['ma30'] = ta.EMA(df, timeperiod=30).fillna(0)
|
||||
df['ma250'] = ta.MA(df, timeperiod=250).fillna(0)
|
||||
|
||||
# RSI指标
|
||||
df['rsi'] = ta.RSI(df, timeperiod=14).fillna(0)
|
||||
|
||||
# 成交量指标
|
||||
df['avg_volume'] = df['volume'].rolling(10).mean().fillna(0)
|
||||
df['volume_ratio'] = (df['volume'] / df['avg_volume']).fillna(1.0)
|
||||
|
||||
# 处理Infinity和-Infinity值
|
||||
df['volume_ratio'] = df['volume_ratio'].replace([float('inf'), float('-inf')], 1.0)
|
||||
|
||||
# 确保所有指标列都不包含NaN或无限值
|
||||
indicator_columns = ['macd', 'macdsignal', 'macdhist', 'ma5', 'ma10', 'ma30', 'ma250', 'rsi', 'avg_volume', 'volume_ratio']
|
||||
for col in indicator_columns:
|
||||
if col in df.columns:
|
||||
# 替换NaN、inf、-inf为合理的默认值
|
||||
df[col] = df[col].replace([np.nan, np.inf, -np.inf], 0 if col != 'volume_ratio' else 1.0)
|
||||
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
return df
|
||||
|
||||
def search_stock(self, keyword):
|
||||
"""搜索股票 - 支持代码和名称模糊搜索"""
|
||||
try:
|
||||
if not keyword or len(keyword.strip()) == 0:
|
||||
return []
|
||||
|
||||
keyword = keyword.strip().upper()
|
||||
results = []
|
||||
|
||||
# 从热门股票中搜索
|
||||
popular_stocks = self.get_popular_stocks()
|
||||
for stock in popular_stocks:
|
||||
if (keyword in stock['symbol'] or
|
||||
keyword.lower() in stock['name'].lower() or
|
||||
stock['symbol'].startswith(keyword)):
|
||||
results.append({
|
||||
'symbol': stock['symbol'],
|
||||
'name': stock['name'],
|
||||
'sector': stock.get('sector', ''),
|
||||
'source': '热门股票'
|
||||
})
|
||||
|
||||
# 如果热门股票中找到的结果少于10个,从完整股票列表中搜索
|
||||
if len(results) < 10:
|
||||
try:
|
||||
# 获取完整股票列表进行搜索
|
||||
stock_info = ak.stock_zh_a_spot_em()
|
||||
|
||||
# 搜索前1000只活跃股票
|
||||
for index, row in stock_info.head(1000).iterrows():
|
||||
stock_code = str(row['代码'])
|
||||
stock_name = str(row['名称'])
|
||||
|
||||
# 过滤ST股票
|
||||
if 'ST' in stock_name or '*' in stock_name:
|
||||
continue
|
||||
|
||||
# 检查是否已经在结果中
|
||||
if any(r['symbol'] == stock_code for r in results):
|
||||
continue
|
||||
|
||||
# 搜索匹配
|
||||
if (keyword in stock_code or
|
||||
keyword.lower() in stock_name.lower() or
|
||||
stock_code.startswith(keyword)):
|
||||
results.append({
|
||||
'symbol': stock_code,
|
||||
'name': stock_name,
|
||||
'price': float(row['最新价']) if pd.notna(row['最新价']) else 0.0,
|
||||
'change_pct': float(row['涨跌幅']) if pd.notna(row['涨跌幅']) else 0.0,
|
||||
'source': '全市场搜索'
|
||||
})
|
||||
|
||||
# 限制结果数量
|
||||
if len(results) >= 30:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# 排序:优先显示代码匹配的结果
|
||||
def sort_key(item):
|
||||
if item['symbol'].startswith(keyword):
|
||||
return (0, item['symbol']) # 代码开头匹配优先级最高
|
||||
elif keyword in item['symbol']:
|
||||
return (1, item['symbol']) # 代码包含匹配次之
|
||||
else:
|
||||
return (2, item['symbol']) # 名称匹配最后
|
||||
|
||||
results.sort(key=sort_key)
|
||||
|
||||
# 限制返回结果数量
|
||||
return results[:20]
|
||||
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
def get_stock_by_sector(self, sector=None):
|
||||
"""根据行业获取股票列表"""
|
||||
try:
|
||||
popular_stocks = self.get_popular_stocks()
|
||||
if sector:
|
||||
return [stock for stock in popular_stocks if stock.get('sector', '') == sector]
|
||||
else:
|
||||
# 按行业分组
|
||||
sectors = {}
|
||||
for stock in popular_stocks:
|
||||
sector_name = stock.get('sector', '其他')
|
||||
if sector_name not in sectors:
|
||||
sectors[sector_name] = []
|
||||
sectors[sector_name].append(stock)
|
||||
return sectors
|
||||
except Exception as e:
|
||||
return {} if sector is None else []
|
||||
|
||||
def get_all_sectors(self):
|
||||
"""获取所有行业分类"""
|
||||
try:
|
||||
popular_stocks = self.get_popular_stocks()
|
||||
sectors = set()
|
||||
for stock in popular_stocks:
|
||||
sector = stock.get('sector', '其他')
|
||||
sectors.add(sector)
|
||||
return sorted(list(sectors))
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
def is_trading_day(self, date):
|
||||
"""判断是否为交易日(排除周末和节假日)"""
|
||||
try:
|
||||
# 将日期转换为datetime对象
|
||||
if isinstance(date, str):
|
||||
date = datetime.strptime(date.split()[0], '%Y-%m-%d')
|
||||
elif isinstance(date, pd.Timestamp):
|
||||
date = date.to_pydatetime()
|
||||
|
||||
# 周末不是交易日
|
||||
if date.weekday() >= 5: # 5=周六, 6=周日
|
||||
return False
|
||||
|
||||
# 这里可以进一步添加节假日判断
|
||||
# 目前暂时只过滤周末
|
||||
return True
|
||||
except Exception as e:
|
||||
return True # 默认返回True,避免过度过滤
|
||||
|
||||
def is_trading_time(self, dt):
|
||||
"""判断是否为交易时间"""
|
||||
try:
|
||||
if isinstance(dt, str):
|
||||
dt = pd.to_datetime(dt)
|
||||
|
||||
time_str = dt.strftime('%H:%M')
|
||||
|
||||
# 上午交易时间:09:30-11:30
|
||||
morning_start = self.trading_hours['morning']['start']
|
||||
morning_end = self.trading_hours['morning']['end']
|
||||
|
||||
# 下午交易时间:13:00-15:00
|
||||
afternoon_start = self.trading_hours['afternoon']['start']
|
||||
afternoon_end = self.trading_hours['afternoon']['end']
|
||||
|
||||
return ((morning_start <= time_str <= morning_end) or
|
||||
(afternoon_start <= time_str <= afternoon_end))
|
||||
except Exception as e:
|
||||
return True # 默认返回True,避免过度过滤
|
||||
|
||||
def adjust_timestamp_for_trading_hours(self, df, timeframe):
|
||||
"""根据A股交易时间调整时间戳"""
|
||||
try:
|
||||
if df is None or len(df) == 0:
|
||||
return df
|
||||
|
||||
# 确保date列是datetime类型
|
||||
if 'date' in df.columns:
|
||||
df['date'] = pd.to_datetime(df['date'])
|
||||
|
||||
# 对于日线数据,设置为收盘时间(15:00)
|
||||
if timeframe == '1d':
|
||||
df['date'] = df['date'].dt.normalize() + pd.Timedelta(hours=15)
|
||||
|
||||
# 对于分钟级数据,过滤非交易时间的数据
|
||||
elif timeframe in ['1m', '5m', '15m', '30m', '1h']:
|
||||
# 过滤交易日
|
||||
df = df[df['date'].apply(self.is_trading_day)]
|
||||
|
||||
# 过滤交易时间(只在有足够数据时进行)
|
||||
if len(df) > 10: # 避免过度过滤导致数据不足
|
||||
df = df[df['date'].apply(self.is_trading_time)]
|
||||
|
||||
# 重新计算时间戳
|
||||
if 'date' in df.columns:
|
||||
# 将时间转换为上海时区
|
||||
df['date'] = df['date'].dt.tz_localize('Asia/Shanghai', ambiguous='infer', nonexistent='shift_forward')
|
||||
# 转换为毫秒时间戳
|
||||
df['timestamp'] = df['date'].astype('int64') // 10**6
|
||||
|
||||
return df.reset_index(drop=True)
|
||||
|
||||
except Exception as e:
|
||||
return df
|
||||
|
||||
def get_trading_calendar(self, start_date, end_date):
|
||||
"""获取交易日历(简化版本)"""
|
||||
try:
|
||||
# 使用akshare获取交易日历
|
||||
trading_calendar = ak.tool_trade_date_hist_sina()
|
||||
|
||||
# 过滤指定日期范围
|
||||
start_dt = pd.to_datetime(start_date)
|
||||
end_dt = pd.to_datetime(end_date)
|
||||
|
||||
trading_days = []
|
||||
for _, row in trading_calendar.iterrows():
|
||||
trade_date = pd.to_datetime(row['trade_date'])
|
||||
if start_dt <= trade_date <= end_dt:
|
||||
trading_days.append(trade_date.strftime('%Y-%m-%d'))
|
||||
|
||||
return trading_days
|
||||
except Exception as e:
|
||||
# 如果获取失败,生成简单的工作日列表(排除周末)
|
||||
trading_days = []
|
||||
current = pd.to_datetime(start_date)
|
||||
end = pd.to_datetime(end_date)
|
||||
|
||||
while current <= end:
|
||||
if current.weekday() < 5: # 周一到周五
|
||||
trading_days.append(current.strftime('%Y-%m-%d'))
|
||||
current += timedelta(days=1)
|
||||
|
||||
return trading_days
|
||||
|
||||
def fill_trading_gaps(self, df, timeframe):
|
||||
"""填补A股交易时间间隙,确保图表连续性"""
|
||||
try:
|
||||
if df is None or len(df) == 0:
|
||||
return df
|
||||
|
||||
# 对于日线数据,不需要填补间隙,因为本来就是每日一个数据点
|
||||
if timeframe == '1d':
|
||||
return df
|
||||
|
||||
# 对于分钟级数据,创建完整的交易时间序列
|
||||
if timeframe in ['1m', '5m', '15m', '30m', '1h']:
|
||||
# 获取数据的开始和结束时间
|
||||
start_date = df['date'].min().date()
|
||||
end_date = df['date'].max().date()
|
||||
|
||||
# 创建完整的交易时间序列
|
||||
complete_times = []
|
||||
current_date = start_date
|
||||
|
||||
# 获取时间间隔(分钟)
|
||||
freq_map = {'1m': 1, '5m': 5, '15m': 15, '30m': 30, '1h': 60}
|
||||
freq_minutes = freq_map.get(timeframe, 5)
|
||||
|
||||
while current_date <= end_date:
|
||||
# 只处理交易日
|
||||
if self.is_trading_day(current_date):
|
||||
# 上午交易时间 - 使用datetime.time而不是pd.Time
|
||||
morning_start = pd.Timestamp.combine(current_date, time(9, 30))
|
||||
morning_end = pd.Timestamp.combine(current_date, time(11, 30))
|
||||
|
||||
# 下午交易时间
|
||||
afternoon_start = pd.Timestamp.combine(current_date, time(13, 0))
|
||||
afternoon_end = pd.Timestamp.combine(current_date, time(15, 0))
|
||||
|
||||
# 生成上午时间序列
|
||||
current_time = morning_start
|
||||
while current_time <= morning_end:
|
||||
complete_times.append(current_time)
|
||||
current_time += pd.Timedelta(minutes=freq_minutes)
|
||||
|
||||
# 生成下午时间序列
|
||||
current_time = afternoon_start
|
||||
while current_time <= afternoon_end:
|
||||
complete_times.append(current_time)
|
||||
current_time += pd.Timedelta(minutes=freq_minutes)
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# 创建完整时间序列的DataFrame
|
||||
if complete_times:
|
||||
complete_df = pd.DataFrame({'date': complete_times})
|
||||
complete_df['date'] = complete_df['date'].dt.tz_localize('Asia/Shanghai')
|
||||
complete_df['timestamp'] = complete_df['date'].astype('int64') // 10**6
|
||||
|
||||
# 将原始数据合并到完整时间序列
|
||||
# 使用时间戳进行合并,避免时区问题
|
||||
df_merged = pd.merge(complete_df, df, on='timestamp', how='left', suffixes=('', '_orig'))
|
||||
|
||||
# 保持原有date列
|
||||
df_merged['date'] = df_merged['date']
|
||||
|
||||
# 对于缺失的OHLCV数据,使用前向填充
|
||||
price_cols = ['open', 'high', 'low', 'close']
|
||||
for col in price_cols:
|
||||
if col in df_merged.columns:
|
||||
df_merged[col] = df_merged[col].ffill()
|
||||
|
||||
# 成交量缺失时设为0
|
||||
if 'volume' in df_merged.columns:
|
||||
df_merged['volume'] = df_merged['volume'].fillna(0)
|
||||
|
||||
# 删除辅助列
|
||||
cols_to_drop = [col for col in df_merged.columns if col.endswith('_orig')]
|
||||
df_merged = df_merged.drop(columns=cols_to_drop)
|
||||
|
||||
return df_merged
|
||||
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
return df
|
||||
|
||||
def clean_a_stock_data(self, df, timeframe):
|
||||
"""清理A股数据,处理异常值和时间问题"""
|
||||
try:
|
||||
if df is None or len(df) == 0:
|
||||
return df
|
||||
|
||||
import numpy as np
|
||||
|
||||
# 首先删除所有包含NaN的行
|
||||
df = df.dropna()
|
||||
|
||||
# 删除价格异常的数据
|
||||
price_cols = ['open', 'high', 'low', 'close']
|
||||
for col in price_cols:
|
||||
if col in df.columns:
|
||||
# 删除价格为0、负数、NaN、inf的记录
|
||||
df = df[df[col] > 0]
|
||||
df = df[np.isfinite(df[col])]
|
||||
|
||||
# 检查OHLC逻辑合理性
|
||||
if all(col in df.columns for col in price_cols):
|
||||
# high应该是最高价
|
||||
df = df[df['high'] >= df['open']]
|
||||
df = df[df['high'] >= df['close']]
|
||||
# low应该是最低价
|
||||
df = df[df['low'] <= df['open']]
|
||||
df = df[df['low'] <= df['close']]
|
||||
# high应该大于等于low
|
||||
df = df[df['high'] >= df['low']]
|
||||
|
||||
# 删除成交量异常的数据
|
||||
if 'volume' in df.columns:
|
||||
# 删除成交量为负数、NaN、inf的记录
|
||||
df = df[df['volume'] >= 0]
|
||||
df = df[np.isfinite(df['volume'])]
|
||||
|
||||
# 确保所有数值列都不包含NaN或无限值
|
||||
numeric_cols = df.select_dtypes(include=[np.number]).columns
|
||||
for col in numeric_cols:
|
||||
# 替换NaN、inf、-inf为0(除了价格列,价格列的异常值已经被过滤掉了)
|
||||
if col not in price_cols:
|
||||
df[col] = df[col].replace([np.nan, np.inf, -np.inf], 0)
|
||||
|
||||
# 确保时间序列连续性(仅对分钟级数据)
|
||||
if timeframe in ['1m', '5m', '15m', '30m', '1h']:
|
||||
df = self.fill_trading_gaps(df, timeframe)
|
||||
|
||||
# 最后再次检查并清理任何剩余的NaN值
|
||||
df = df.dropna()
|
||||
|
||||
return df.reset_index(drop=True)
|
||||
|
||||
except Exception as e:
|
||||
return df
|
||||
@@ -0,0 +1,14 @@
|
||||
"""行情数据服务。"""
|
||||
from services.runtime import ( # noqa: F401
|
||||
exchange,
|
||||
china_stock,
|
||||
DATA_SERVICE_AVAILABLE,
|
||||
SYMBOLS,
|
||||
DEFAULT_SYMBOLS,
|
||||
refresh_data_service_metadata,
|
||||
get_kl_data,
|
||||
get_crypto_kl_data,
|
||||
get_a_stock_kl_data,
|
||||
detect_symbol_type,
|
||||
load_crypto_symbols,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
"""序列化与 JSON 清洗。"""
|
||||
from services.runtime import ( # noqa: F401
|
||||
convert_direction,
|
||||
format_time_safely,
|
||||
serialize_chan_macd_data,
|
||||
clean_dataframe_for_json,
|
||||
get_uncompleted_seg_list,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""时间周期工具。"""
|
||||
from services.runtime import ( # noqa: F401
|
||||
timeframe_to_minutes,
|
||||
format_timeframe_label,
|
||||
build_timeframe_labels,
|
||||
compute_timeframe_defaults,
|
||||
is_smaller_timeframe,
|
||||
is_smaller_or_equal_timeframe,
|
||||
DEFAULT_TIMEFRAME_LABELS,
|
||||
TIMEFRAMES,
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
/* Chan web API client helpers */
|
||||
window.ChanApi = {
|
||||
analyze: function(params) {
|
||||
const q = new URLSearchParams(params);
|
||||
return fetch('/api/analyze?' + q.toString()).then(r => r.json());
|
||||
},
|
||||
chartMetadata: function() {
|
||||
return fetch('/api/chart_metadata').then(r => r.json());
|
||||
},
|
||||
symbols: function() {
|
||||
return fetch('/api/symbols').then(r => r.json());
|
||||
},
|
||||
macdConfig: function(body) {
|
||||
if (body === undefined) return fetch('/api/macd_config').then(r => r.json());
|
||||
return fetch('/api/macd_config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
}).then(r => r.json());
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* 缠论自定义指标 — TradingView Advanced Chart
|
||||
*
|
||||
* 从 chanIndicator.ts 转换为 vanilla JS。
|
||||
* 在 K 线上叠加:笔/段(实线+虚线)、中枢(填色区域)、买卖点(文字标签)。
|
||||
*
|
||||
* 依赖:
|
||||
* window.chanLookupHolder — 当前 Chan 结构数据
|
||||
* window.commitChanLookup — 累积合并新数据
|
||||
* window.makeChanIndicator — 创建 TV study 定义
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict'
|
||||
|
||||
// ---- BSP 子类型枚举 ----
|
||||
var BSP_SUBTYPES = ['T1', 'T1P', 'T2', 'T2S', 'T3A', 'T3B']
|
||||
|
||||
// ---- 全局状态: chanLookupHolder ----
|
||||
window.chanLookupHolder = {
|
||||
current: null,
|
||||
key: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* 累积/替换 chanLookup
|
||||
* 同 key 累积合并(历史区间的 BSP 标签持续保留)
|
||||
* 不同 key 整个替换
|
||||
*/
|
||||
window.commitChanLookup = function (fresh, key) {
|
||||
var holder = window.chanLookupHolder
|
||||
if (holder.key !== key || !holder.current) {
|
||||
holder.current = fresh
|
||||
holder.key = key
|
||||
return
|
||||
}
|
||||
// 同 key 合并
|
||||
var target = holder.current.byTimeMs
|
||||
fresh.byTimeMs.forEach(function (e, t) {
|
||||
var existed = target.get(t)
|
||||
if (existed) {
|
||||
Object.assign(existed, e)
|
||||
} else {
|
||||
target.set(t, e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 工具函数 ----
|
||||
|
||||
function lowerBound(arr, v) {
|
||||
var lo = 0, hi = arr.length
|
||||
while (lo < hi) {
|
||||
var mid = (lo + hi) >> 1
|
||||
if (arr[mid] < v) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
function upperBound(arr, v) {
|
||||
var lo = 0, hi = arr.length
|
||||
while (lo < hi) {
|
||||
var mid = (lo + hi) >> 1
|
||||
if (arr[mid] <= v) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 ChanLookup:将 Chan 结构数据映射到每个 bar 的指标值
|
||||
*
|
||||
* @param {Object} slice - ChanSlice {bis, segs, zs, segzs, bsps, seg_bsps}
|
||||
* @param {Array} bars - OHLCV bars [{t: ms, h, l}, ...]
|
||||
* @returns {Object} {byTimeMs: Map<ms, BarEntry>}
|
||||
*/
|
||||
window.buildChanLookup = function (slice, bars) {
|
||||
var byTimeMs = new Map()
|
||||
|
||||
function ensure(tsMs) {
|
||||
// tsMs 已是毫秒(来自 data_provider 的 timestamp),无需再转换
|
||||
var key = tsMs
|
||||
var e = byTimeMs.get(key)
|
||||
if (!e) {
|
||||
e = {}
|
||||
byTimeMs.set(key, e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
var sortedBarTimes = bars.map(function (b) { return b.t }).sort(function (a, b) { return a - b })
|
||||
|
||||
// 线性插值填充笔/段到每个 bar
|
||||
function fillLine(t0, t1, p0, p1, field) {
|
||||
var lo = lowerBound(sortedBarTimes, t0)
|
||||
var hi = upperBound(sortedBarTimes, t1)
|
||||
var span = hi - 1 - lo
|
||||
if (span <= 0) {
|
||||
if (lo < sortedBarTimes.length) ensure(sortedBarTimes[lo])[field] = p0
|
||||
return
|
||||
}
|
||||
var step = (p1 - p0) / span
|
||||
for (var i = lo; i < hi; i++) {
|
||||
ensure(sortedBarTimes[i])[field] = p0 + step * (i - lo)
|
||||
}
|
||||
}
|
||||
|
||||
// 笔
|
||||
if (slice.bis) {
|
||||
slice.bis.forEach(function (b) {
|
||||
fillLine(b.t0, b.t1, b.p0, b.p1, b.sure ? 'bi' : 'bi_pending')
|
||||
})
|
||||
}
|
||||
|
||||
// 段
|
||||
if (slice.segs) {
|
||||
slice.segs.forEach(function (s) {
|
||||
fillLine(s.t0, s.t1, s.p0, s.p1, s.sure ? 'seg' : 'seg_pending')
|
||||
})
|
||||
}
|
||||
|
||||
// 中枢填充:区间内每根 bar 写入 top/bottom
|
||||
function fillZs(t0, t1, high, low, topField, botField) {
|
||||
var lo = lowerBound(sortedBarTimes, t0)
|
||||
var hi = upperBound(sortedBarTimes, t1)
|
||||
for (var i = lo; i < hi; i++) {
|
||||
var e = ensure(sortedBarTimes[i])
|
||||
e[topField] = high
|
||||
e[botField] = low
|
||||
}
|
||||
}
|
||||
|
||||
if (slice.zs) {
|
||||
slice.zs.forEach(function (z) {
|
||||
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom')
|
||||
})
|
||||
}
|
||||
if (slice.segzs) {
|
||||
slice.segzs.forEach(function (z) {
|
||||
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom')
|
||||
})
|
||||
}
|
||||
|
||||
// BSP 买卖点标记
|
||||
function placeBsps(list, prefix) {
|
||||
if (!list) return
|
||||
list.forEach(function (bsp) {
|
||||
var dir = bsp.is_buy ? 'buy' : 'sell'
|
||||
var e = ensure(bsp.t)
|
||||
var types = bsp.types || []
|
||||
types.forEach(function (raw) {
|
||||
var t = String(raw).toUpperCase()
|
||||
if (BSP_SUBTYPES.indexOf(t) === -1) return
|
||||
var key = prefix + '_' + dir + '_' + t
|
||||
e[key] = 1
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
placeBsps(slice.bsps, 'bi_bsp')
|
||||
placeBsps(slice.seg_bsps, 'seg_bsp')
|
||||
|
||||
return { byTimeMs: byTimeMs }
|
||||
}
|
||||
|
||||
// ---- 样式持久化 ----
|
||||
|
||||
function currentTheme() {
|
||||
try { return localStorage.getItem('chart-theme') || 'light' }
|
||||
catch (e) { return 'light' }
|
||||
}
|
||||
|
||||
function chanStyleKey() {
|
||||
return 'chan-indicator-styles-v7-' + currentTheme()
|
||||
}
|
||||
|
||||
function loadSavedChanStyles() {
|
||||
try {
|
||||
var raw = localStorage.getItem(chanStyleKey())
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
window.saveChanStyles = function (sv) {
|
||||
try {
|
||||
localStorage.setItem(chanStyleKey(), JSON.stringify({
|
||||
styles: sv && sv.styles ? sv.styles : {},
|
||||
filledAreasStyle: sv && sv.filledAreasStyle ? sv.filledAreasStyle : {},
|
||||
}))
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---- 主体:创建 TV 自定义指标定义 ----
|
||||
|
||||
window.makeChanIndicator = function () {
|
||||
var saved = loadSavedChanStyles()
|
||||
var isDark = currentTheme() === 'dark'
|
||||
var biColor = isDark ? '#ffffff' : '#000000'
|
||||
var segColor = isDark ? '#42a5f5' : '#1565c0'
|
||||
|
||||
function mergeStyle(id, base) {
|
||||
var savedStyle = (saved && saved.styles && saved.styles[id]) || {}
|
||||
var merged = {}
|
||||
var keys = Object.keys(base).concat(Object.keys(savedStyle))
|
||||
keys.forEach(function (k) {
|
||||
if (k in savedStyle) merged[k] = savedStyle[k]
|
||||
else merged[k] = base[k]
|
||||
})
|
||||
return merged
|
||||
}
|
||||
|
||||
function mergeFill(id, base) {
|
||||
var savedFill = (saved && saved.filledAreasStyle && saved.filledAreasStyle[id]) || {}
|
||||
var merged = {}
|
||||
var keys = Object.keys(base).concat(Object.keys(savedFill))
|
||||
keys.forEach(function (k) {
|
||||
if (k in savedFill) merged[k] = savedFill[k]
|
||||
else merged[k] = base[k]
|
||||
})
|
||||
return merged
|
||||
}
|
||||
|
||||
// 构建 plots 数组
|
||||
var plots = [
|
||||
{ id: 'bi', type: 'line' },
|
||||
{ id: 'bi_pending', type: 'line' },
|
||||
{ id: 'seg', type: 'line' },
|
||||
{ id: 'seg_pending', type: 'line' },
|
||||
{ id: 'zs_top', type: 'line' },
|
||||
{ id: 'zs_bottom', type: 'line' },
|
||||
{ id: 'segzs_top', type: 'line' },
|
||||
{ id: 'segzs_bottom', type: 'line' },
|
||||
]
|
||||
|
||||
BSP_SUBTYPES.forEach(function (t) {
|
||||
plots.push({ id: 'bi_bsp_buy_' + t, type: 'chars' })
|
||||
plots.push({ id: 'bi_bsp_sell_' + t, type: 'chars' })
|
||||
plots.push({ id: 'seg_bsp_buy_' + t, type: 'chars' })
|
||||
plots.push({ id: 'seg_bsp_sell_' + t, type: 'chars' })
|
||||
})
|
||||
|
||||
// 构建 styles 对象
|
||||
// bi_pending/seg_pending: 虚线(linestyle:2),加粗 + 高亮色,确保末完成笔/段清晰可见
|
||||
var pendingBiColor = isDark ? '#ff9800' : '#e65100' // orange
|
||||
var pendingSegColor = isDark ? '#e040fb' : '#aa00ff' // purple
|
||||
var styles = {
|
||||
bi: mergeStyle('bi', {
|
||||
linestyle: 0, linewidth: 1, plottype: 0, trackPrice: false,
|
||||
transparency: 0, visible: true, color: biColor, display: 3,
|
||||
}),
|
||||
bi_pending: mergeStyle('bi_pending', {
|
||||
linestyle: 2, linewidth: 2, plottype: 0, trackPrice: false,
|
||||
transparency: 0, visible: true, color: pendingBiColor, display: 3,
|
||||
}),
|
||||
seg: mergeStyle('seg', {
|
||||
linestyle: 0, linewidth: 3, plottype: 0, trackPrice: false,
|
||||
transparency: 0, visible: true, color: segColor, display: 3,
|
||||
}),
|
||||
seg_pending: mergeStyle('seg_pending', {
|
||||
linestyle: 2, linewidth: 4, plottype: 0, trackPrice: false,
|
||||
transparency: 0, visible: true, color: pendingSegColor, display: 3,
|
||||
}),
|
||||
zs_top: mergeStyle('zs_top', {
|
||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||
transparency: 100, visible: false, color: '#e4eaf1', display: 0,
|
||||
}),
|
||||
zs_bottom: mergeStyle('zs_bottom', {
|
||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||
transparency: 100, visible: false, color: '#1565c0', display: 0,
|
||||
}),
|
||||
segzs_top: mergeStyle('segzs_top', {
|
||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||
transparency: 100, visible: false, color: '#ef6c00', display: 0,
|
||||
}),
|
||||
segzs_bottom: mergeStyle('segzs_bottom', {
|
||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||
transparency: 100, visible: false, color: '#ef6c00', display: 0,
|
||||
}),
|
||||
}
|
||||
|
||||
// BSP 样式
|
||||
BSP_SUBTYPES.forEach(function (t) {
|
||||
styles['bi_bsp_buy_' + t] = mergeStyle('bi_bsp_buy_' + t, {
|
||||
char: '●', location: 'BelowBar', visible: true, size: 'large',
|
||||
color: '#d32f2f', display: 3,
|
||||
})
|
||||
styles['bi_bsp_sell_' + t] = mergeStyle('bi_bsp_sell_' + t, {
|
||||
char: '●', location: 'AboveBar', visible: true, size: 'large',
|
||||
color: '#2e7d32', display: 3,
|
||||
})
|
||||
styles['seg_bsp_buy_' + t] = mergeStyle('seg_bsp_buy_' + t, {
|
||||
char: '●', location: 'BelowBar', visible: true, size: 'large',
|
||||
color: '#d32f2f', display: 3,
|
||||
})
|
||||
styles['seg_bsp_sell_' + t] = mergeStyle('seg_bsp_sell_' + t, {
|
||||
char: '●', location: 'AboveBar', visible: true, size: 'large',
|
||||
color: '#2e7d32', display: 3,
|
||||
})
|
||||
})
|
||||
|
||||
// 构建 style titles
|
||||
var styleTitles = {
|
||||
bi: { title: '笔', histogramBase: 0 },
|
||||
bi_pending: { title: '笔(虚)', histogramBase: 0 },
|
||||
seg: { title: '段', histogramBase: 0 },
|
||||
seg_pending: { title: '段(虚)', histogramBase: 0 },
|
||||
zs_top: { title: '中枢上沿', histogramBase: 0, isHidden: true },
|
||||
zs_bottom: { title: '中枢下沿', histogramBase: 0, isHidden: true },
|
||||
segzs_top: { title: '段中枢上沿', histogramBase: 0, isHidden: true },
|
||||
segzs_bottom: { title: '段中枢下沿', histogramBase: 0, isHidden: true },
|
||||
}
|
||||
|
||||
BSP_SUBTYPES.forEach(function (t) {
|
||||
// 类型名映射:T1/T2/T3A 是买点, T1P/T2S/T3B 是卖点
|
||||
var typeInfo = {
|
||||
T1: { cls: '一', side: 'buy', num: '1' },
|
||||
T1P: { cls: '一', side: 'sell', num: '1' },
|
||||
T2: { cls: '二', side: 'buy', num: '2' },
|
||||
T2S: { cls: '二', side: 'sell', num: '2' },
|
||||
T3A: { cls: '三', side: 'buy', num: '3' },
|
||||
T3B: { cls: '三', side: 'sell', num: '3' },
|
||||
}[t] || { cls: '', side: '', num: '' }
|
||||
var buyText = 'B' + typeInfo.num
|
||||
var sellText = 'S' + typeInfo.num
|
||||
var isBuyType = typeInfo.side === 'buy'
|
||||
var isSellType = typeInfo.side === 'sell'
|
||||
|
||||
// 笔中枢 BSP:全部可见
|
||||
styleTitles['bi_bsp_buy_' + t] = {
|
||||
title: '笔·' + typeInfo.cls + '类买点',
|
||||
isHidden: !isBuyType,
|
||||
text: buyText,
|
||||
}
|
||||
styleTitles['bi_bsp_sell_' + t] = {
|
||||
title: '笔·' + typeInfo.cls + '类卖点',
|
||||
isHidden: !isSellType,
|
||||
text: sellText,
|
||||
}
|
||||
// 段中枢 BSP:只有一类买卖点有实际数据
|
||||
var segBuyVisible = t === 'T1'
|
||||
var segSellVisible = t === 'T1P'
|
||||
styleTitles['seg_bsp_buy_' + t] = {
|
||||
title: '段·一类买点',
|
||||
isHidden: !segBuyVisible,
|
||||
text: '段B1',
|
||||
}
|
||||
styleTitles['seg_bsp_sell_' + t] = {
|
||||
title: '段·一类卖点',
|
||||
isHidden: !segSellVisible,
|
||||
text: '段S1',
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
name: '缠论',
|
||||
metainfo: {
|
||||
_metainfoVersion: 53,
|
||||
id: 'Chan@tv-basicstudies-5',
|
||||
scriptIdPart: '',
|
||||
description: 'Chan 缠论',
|
||||
shortDescription: '缠论',
|
||||
is_hidden_study: false,
|
||||
isCustomIndicator: true,
|
||||
is_price_study: true,
|
||||
linkedToSeries: true,
|
||||
format: { type: 'inherit' },
|
||||
plots: plots,
|
||||
filledAreas: [
|
||||
{ id: 'zs_fill', objAId: 'zs_top', objBId: 'zs_bottom', type: 'plot_plot',
|
||||
title: '中枢', isHidden: false },
|
||||
{ id: 'segzs_fill', objAId: 'segzs_top', objBId: 'segzs_bottom', type: 'plot_plot',
|
||||
title: '段中枢', isHidden: false },
|
||||
],
|
||||
defaults: {
|
||||
styles: styles,
|
||||
filledAreasStyle: {
|
||||
zs_fill: mergeFill('zs_fill', { color: '#f1d96a', visible: true, transparency: 75 }),
|
||||
segzs_fill: mergeFill('segzs_fill', { color: '#6361f7', visible: true, transparency: 75 }),
|
||||
},
|
||||
precision: 2,
|
||||
inputs: { epoch: 0 },
|
||||
},
|
||||
styles: styleTitles,
|
||||
inputs: [
|
||||
{ id: 'epoch', name: 'epoch', type: 'integer', defval: 0, isHidden: true },
|
||||
],
|
||||
},
|
||||
constructor: function () {
|
||||
var self = this
|
||||
this.init = function (ctx) {
|
||||
self._context = ctx
|
||||
}
|
||||
this.main = function (context) {
|
||||
// 32 个 plot: 8 结构 + 24 BSP
|
||||
var NANS = new Array(32).fill(NaN)
|
||||
// v31: sniffing pass 时 context.symbol.time 为 NaN
|
||||
var t = context.symbol.time
|
||||
if (isNaN(t)) return NANS
|
||||
|
||||
var lookup = window.chanLookupHolder.current
|
||||
if (!lookup) return NANS
|
||||
|
||||
var e = lookup.byTimeMs.get(t)
|
||||
if (!e) return NANS
|
||||
|
||||
var out = [
|
||||
e.bi != null ? e.bi : NaN,
|
||||
e.bi_pending != null ? e.bi_pending : NaN,
|
||||
e.seg != null ? e.seg : NaN,
|
||||
e.seg_pending != null ? e.seg_pending : NaN,
|
||||
e.zs_top != null ? e.zs_top : NaN,
|
||||
e.zs_bottom != null ? e.zs_bottom : NaN,
|
||||
e.segzs_top != null ? e.segzs_top : NaN,
|
||||
e.segzs_bottom != null ? e.segzs_bottom : NaN,
|
||||
]
|
||||
|
||||
BSP_SUBTYPES.forEach(function (sub) {
|
||||
out.push(
|
||||
e['bi_bsp_buy_' + sub] != null ? e['bi_bsp_buy_' + sub] : NaN,
|
||||
e['bi_bsp_sell_' + sub] != null ? e['bi_bsp_sell_' + sub] : NaN,
|
||||
e['seg_bsp_buy_' + sub] != null ? e['seg_bsp_buy_' + sub] : NaN,
|
||||
e['seg_bsp_sell_' + sub] != null ? e['seg_bsp_sell_' + sub] : NaN
|
||||
)
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Epoch bump 机制 ----
|
||||
var chanEpoch = 0
|
||||
var CHAN_STUDY_DESC = 'Chan 缠论'
|
||||
|
||||
/**
|
||||
* 确保缠论 study 存在并通过 epoch bump 触发重绘。
|
||||
* 与 TradingViewChart.tsx 中 ensureAndPokeChanStudy 逻辑一致。
|
||||
*/
|
||||
window.ensureAndPokeChanStudy = function (chart) {
|
||||
try {
|
||||
var studies = chart.getAllStudies ? chart.getAllStudies() : []
|
||||
var existingId = null
|
||||
for (var i = 0; i < studies.length; i++) {
|
||||
if (studies[i].name === CHAN_STUDY_DESC) {
|
||||
existingId = studies[i].id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
chanEpoch += 1
|
||||
|
||||
if (existingId) {
|
||||
try {
|
||||
var api = chart.getStudyById(existingId)
|
||||
if (api && api.setInputValues) {
|
||||
api.setInputValues([{ id: 'epoch', value: chanEpoch }])
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('setInputValues Chan failed', err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 新建 study — 必须是 chart.createStudy(...) 保持 this 绑定!
|
||||
if (!chart.createStudy) return
|
||||
var result = chart.createStudy(CHAN_STUDY_DESC, false, false, { epoch: chanEpoch })
|
||||
// createStudy 返回 Promise<string>
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(function (id) {
|
||||
if (!id) {
|
||||
console.warn('[缠论] createStudy 返回空 id(指标未注册成功)')
|
||||
return
|
||||
}
|
||||
console.log('[缠论] study 已创建', id)
|
||||
try {
|
||||
var studyApi = chart.getStudyById(id)
|
||||
if (studyApi && studyApi.bringToFront) studyApi.bringToFront()
|
||||
} catch (err) {
|
||||
console.warn('bringToFront Chan failed', err)
|
||||
}
|
||||
}).catch(function (err) {
|
||||
console.warn('createStudy Chan failed', err)
|
||||
})
|
||||
} else if (result) {
|
||||
// 同步返回(兜底)
|
||||
console.log('[缠论] study 已创建 (sync)', result)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('ensureAndPokeChanStudy error', e)
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1 @@
|
||||
/* chart.js split into chart_format/view/tv/sync/tables — see index.html load order */
|
||||
@@ -0,0 +1,85 @@
|
||||
/* chart_format.js — split from chart.js */
|
||||
/* chart.js */
|
||||
function updateChartDisplay() {
|
||||
if (currentData) {
|
||||
// 检测K线周期是否切换
|
||||
const curPeriod = $('#subSubPeriodKline').is(':checked') ? 'subsub' :
|
||||
($('#elementPeriodKline').is(':checked') ? 'element' : 'main');
|
||||
const periodChanged = (curPeriod !== _lastKlinePeriod);
|
||||
_lastKlinePeriod = curPeriod;
|
||||
|
||||
// 保存当前的可见范围(周期切换时不保留,避免范围越界)
|
||||
if (!periodChanged && tvWidget && tvWidget.mainChart) {
|
||||
try {
|
||||
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||
} catch (e) {
|
||||
window._pendingRestoreView = null;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('更新图表显示');
|
||||
|
||||
// 重新初始化图表(initTradingView 内部会在最终同步时读取 _pendingRestoreView)
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
}
|
||||
}
|
||||
// 确保所有时间处理都使用UTC时间,包括表格数据显示
|
||||
function formatTime(timeStr) {
|
||||
if (!timeStr) return '';
|
||||
|
||||
try {
|
||||
// 使用用户选择的时区
|
||||
const timezone = $('#timezone').val();
|
||||
const date = new Date(timeStr);
|
||||
|
||||
// 添加调试信息
|
||||
console.debug('表格时间格式化:', timeStr, '->',
|
||||
date.toISOString(), '使用时区:', timezone);
|
||||
|
||||
// 使用toLocaleString带时区参数
|
||||
return date.toLocaleString('zh-CN', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('时间格式化错误:', e, timeStr);
|
||||
// 如果格式化失败,返回原始时间字符串
|
||||
return timeStr;
|
||||
}
|
||||
}
|
||||
|
||||
// 专门用于确认时间的格式化函数,处理可能为空的情况
|
||||
function formatConfirmTime(timeStr) {
|
||||
if (!timeStr || timeStr === null || timeStr === 'null' || timeStr === '') {
|
||||
return '<span class="text-muted">未确认</span>';
|
||||
}
|
||||
return formatTime(timeStr);
|
||||
}
|
||||
|
||||
function formatDirection(direction) {
|
||||
const dirText = direction === 1 ? '向上' : '向下';
|
||||
const dirClass = direction === 1 ? 'direction-up' : 'direction-down';
|
||||
return '<span class="' + dirClass + '">' + dirText + '</span>';
|
||||
}
|
||||
|
||||
function formatPrice(price) {
|
||||
return price !== null ? parseFloat(price).toFixed(2) : '';
|
||||
}
|
||||
|
||||
function formatMacdValue(value) {
|
||||
const numValue = parseFloat(value);
|
||||
const valueClass = numValue >= 0 ? 'positive' : 'negative';
|
||||
return '<span class="' + valueClass + '">' + numValue.toFixed(4) + '</span>';
|
||||
}
|
||||
|
||||
function formatTradePointType(type) {
|
||||
const typeText = type > 0 ? `买${Math.abs(type)}` : `卖${Math.abs(type)}`;
|
||||
const typeClass = type > 0 ? 'direction-up' : 'direction-down';
|
||||
return '<span class="' + typeClass + '">' + typeText + '</span>';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
/* chart_sync.js — split from chart.js */
|
||||
function updateTradingViewData() {
|
||||
try {
|
||||
console.log('增量更新图表数据');
|
||||
|
||||
// 检查 currentData 是否存在
|
||||
if (!currentData) {
|
||||
console.error('currentData为空,无法更新图表');
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存当前的可视范围
|
||||
if (tvWidget.mainChart) {
|
||||
tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange();
|
||||
tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
|
||||
}
|
||||
|
||||
// 检查是否显示原始K线
|
||||
const showOriginalKline = $('#showOriginalKline').is(':checked');
|
||||
|
||||
// 检查是否使用次次周期 / 小周期数据
|
||||
const useSubSubPeriod = $('#subSubPeriodKline').is(':checked') &&
|
||||
currentData.sub_sub_timeframe &&
|
||||
currentData.sub_sub_kline_data &&
|
||||
Array.isArray(currentData.sub_sub_kline_data);
|
||||
const useElementPeriod = !useSubSubPeriod &&
|
||||
$('#elementPeriodKline').is(':checked') &&
|
||||
currentData.element_timeframe &&
|
||||
currentData.element_kline_data &&
|
||||
Array.isArray(currentData.element_kline_data);
|
||||
|
||||
// 转换K线数据
|
||||
let candles = [];
|
||||
if (useSubSubPeriod) {
|
||||
console.log('使用次次周期K线数据');
|
||||
candles = currentData.sub_sub_kline_data.map((kline) => {
|
||||
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 if (useElementPeriod) {
|
||||
console.log('使用小周期K线数据');
|
||||
candles = currentData.element_kline_data.map((kline) => {
|
||||
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 if (currentData.kline_data && Array.isArray(currentData.kline_data)) {
|
||||
console.log('使用主周期K线数据');
|
||||
candles = currentData.kline_data.map((kline) => {
|
||||
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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 更新主系列数据(根据klineType)
|
||||
const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
|
||||
if (klineType === 'candlestick' && tvWidget.series.candleSeries) {
|
||||
tvWidget.series.candleSeries.setData(candles);
|
||||
} else if (klineType === 'renko' && tvWidget.series.renkoSeries) {
|
||||
const bricks = buildRenkoFromCandles(candles);
|
||||
tvWidget.series.renkoSeries.setData(bricks);
|
||||
} else if (klineType === 'heikin' && tvWidget.series.heikinSeries) {
|
||||
const hk = buildHeikinFromCandles(candles);
|
||||
tvWidget.series.heikinSeries.setData(hk);
|
||||
} else if (klineType === 'bar' && tvWidget.series.barSeries) {
|
||||
tvWidget.series.barSeries.setData(candles);
|
||||
} else if (klineType === 'line' && tvWidget.series.lineSeries) {
|
||||
const lineData = candles.map(c => ({ time: c.time, value: c.close }));
|
||||
tvWidget.series.lineSeries.setData(lineData);
|
||||
} else if (klineType === 'area' && tvWidget.series.areaSeries) {
|
||||
const areaData = candles.map(c => ({ time: c.time, value: c.close }));
|
||||
tvWidget.series.areaSeries.setData(areaData);
|
||||
} else if (klineType === 'baseline' && tvWidget.series.baselineSeries) {
|
||||
const baseData = candles.map(c => ({ time: c.time, value: c.close }));
|
||||
tvWidget.series.baselineSeries.setData(baseData);
|
||||
} else if (klineType === 'klc' && tvWidget.series.klcSeries) {
|
||||
const klcCandles = buildKLCFromAnalysis(currentData);
|
||||
tvWidget.series.klcSeries.setData(klcCandles);
|
||||
}
|
||||
|
||||
// 更新均线数据
|
||||
addMovingAveragesToChart(candles);
|
||||
|
||||
// 更新布林带数据
|
||||
addBollingerBandsToChart(candles);
|
||||
|
||||
// 更新成交量数据
|
||||
let volumes = [];
|
||||
if (useSubSubPeriod && currentData.sub_sub_kline_data && Array.isArray(currentData.sub_sub_kline_data)) {
|
||||
volumes = currentData.sub_sub_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(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)',
|
||||
};
|
||||
});
|
||||
} else 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(40, 167, 69, 0.5)' : 'rgba(220, 53, 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(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (tvWidget.series.volumeSeries) {
|
||||
tvWidget.series.volumeSeries.setData(volumes);
|
||||
}
|
||||
|
||||
// 更新ATR数据
|
||||
if (tvWidget.series.atrLineSeries) {
|
||||
const atrData = [];
|
||||
const atrDataSource = useSubSubPeriod ?
|
||||
(currentData.sub_sub_atr || currentData.atr) :
|
||||
(useElementPeriod ? (currentData.element_atr || currentData.atr) : currentData.atr);
|
||||
|
||||
if (atrDataSource && Array.isArray(atrDataSource)) {
|
||||
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
|
||||
// 修复:为每个K线时间点都创建ATR数据点,包括没有ATR值的前期数据
|
||||
for (let i = 0; i < klineDataSource.length; i++) {
|
||||
const kline = klineDataSource[i];
|
||||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||||
|
||||
// 为每个时间点都添加数据以保持时间轴对齐,但ATR为0时不显示
|
||||
if (atrDataSource[i] !== undefined) {
|
||||
if (atrDataSource[i] > 0) {
|
||||
// ATR有效值,正常显示
|
||||
atrData.push({
|
||||
time: timestamp,
|
||||
value: atrDataSource[i]
|
||||
});
|
||||
} else {
|
||||
// ATR为0,添加时间点但不显示线条(使用undefined作为value)
|
||||
atrData.push({
|
||||
time: timestamp,
|
||||
value: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔄 增量更新ATR数据点数:', atrData.length);
|
||||
}
|
||||
|
||||
tvWidget.series.atrLineSeries.setData(atrData);
|
||||
}
|
||||
|
||||
// 更新MACD数据
|
||||
if (tvWidget.series.macdLineSeries && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) {
|
||||
// 提取MACD数据
|
||||
const macdData = [];
|
||||
const signalData = [];
|
||||
const histogramData = [];
|
||||
|
||||
for (let i = 0; i < currentData.kline_data.length; i++) {
|
||||
const kline = currentData.kline_data[i];
|
||||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||||
|
||||
if (currentData.macd && currentData.macd.macd && currentData.macd.macd[i] !== undefined) {
|
||||
macdData.push({
|
||||
time: timestamp,
|
||||
value: currentData.macd.macd[i]
|
||||
});
|
||||
|
||||
signalData.push({
|
||||
time: timestamp,
|
||||
value: currentData.macd.signal[i]
|
||||
});
|
||||
|
||||
// 设置直方图颜色
|
||||
const histValue = currentData.macd.histogram[i];
|
||||
histogramData.push({
|
||||
time: timestamp,
|
||||
value: histValue,
|
||||
color: histValue >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tvWidget.series.macdLineSeries.setData(macdData);
|
||||
tvWidget.series.signalLineSeries.setData(signalData);
|
||||
tvWidget.series.histogramSeries.setData(histogramData);
|
||||
}
|
||||
|
||||
// 更新 ChanMACD 数据与自定义标注
|
||||
if (tvWidget.series.chanMacdLineSeries && ((useSubSubPeriod && currentData.sub_sub_macd) || (useElementPeriod && currentData.element_macd) || currentData.macd) && (useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data))) {
|
||||
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
|
||||
const macdDataSource = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd);
|
||||
if (macdDataSource && macdDataSource.macd && macdDataSource.signal && macdDataSource.histogram) {
|
||||
const chanMacdData = [];
|
||||
const chanSignalData = [];
|
||||
const chanHistData = [];
|
||||
for (let i = 0; i < klineDataSource.length; i++) {
|
||||
const kline = klineDataSource[i];
|
||||
if (kline && kline.date && i < macdDataSource.macd.length && macdDataSource.macd[i] !== null && macdDataSource.macd[i] !== undefined) {
|
||||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||||
chanMacdData.push({ time: timestamp, value: macdDataSource.macd[i] });
|
||||
chanSignalData.push({ time: timestamp, value: macdDataSource.signal[i] });
|
||||
chanHistData.push({ time: timestamp, value: macdDataSource.histogram[i], color: macdDataSource.histogram[i] >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)' });
|
||||
}
|
||||
}
|
||||
if (chanMacdData.length > 0) {
|
||||
tvWidget.series.chanMacdLineSeries.setData(chanMacdData);
|
||||
tvWidget.series.chanMacdSignalSeries.setData(chanSignalData);
|
||||
tvWidget.series.chanMacdHistSeries.setData(chanHistData);
|
||||
}
|
||||
}
|
||||
// 重新应用自定义标注(段/UnitTF/HistSet/状态点)
|
||||
try {
|
||||
if (typeof clearChanMacdMarkers === 'function') clearChanMacdMarkers();
|
||||
const cm = useSubSubPeriod ? (currentData.sub_sub_chan_macd || currentData.chan_macd) : (useElementPeriod ? (currentData.element_chan_macd || currentData.chan_macd) : currentData.chan_macd);
|
||||
const allowU = useSubSubPeriod ? !!window.showUOnSubSub : (useElementPeriod ? !!window.showUOnElement : !!window.showUOnMain);
|
||||
if (cm && allowU) {
|
||||
addAllChanMacdMarkers(
|
||||
cm.seg_list || [],
|
||||
cm.unittf_list || [],
|
||||
cm.histset_list || [],
|
||||
{
|
||||
high_position_list: cm.high_position_list || [],
|
||||
high_empty_list: cm.high_empty_list || [],
|
||||
low_position_list: cm.low_position_list || [],
|
||||
low_empty_list: cm.low_empty_list || [],
|
||||
return_zero_list: cm.return_zero_list || [],
|
||||
cross0_up_list: cm.cross0_up_list || [],
|
||||
cross0_down_list: cm.cross0_down_list || []
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('更新ChanMACD标注失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 重新显示笔、线段和中枢等图形
|
||||
redrawFractalElements();
|
||||
|
||||
// 更新EMA52显示
|
||||
updateEMA52Display(currentData);
|
||||
|
||||
// 恢复之前的可视范围 - 优先使用visibleRange以确保时间轴对齐
|
||||
if (tvWidget.mainChart) {
|
||||
if (tvWidget.state.visibleRange) {
|
||||
console.log('🔄 恢复可见范围:', tvWidget.state.visibleRange);
|
||||
tvWidget.mainChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
} else if (tvWidget.state.logicalRange) {
|
||||
console.log('🔄 恢复逻辑范围:', tvWidget.state.logicalRange);
|
||||
tvWidget.mainChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('增量更新图表完成');
|
||||
} catch (e) {
|
||||
console.error('增量更新图表错误,回退到完全重绘:', e);
|
||||
// 出错时回退到完全重绘
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
}
|
||||
}
|
||||
function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, mainChart, volumeChart, atrChart, macdChart, chanMacdChart, showMacd) {
|
||||
// 清理上一轮绑定的事件监听器,防止累积
|
||||
if (window._bindSyncCleanups) {
|
||||
window._bindSyncCleanups.forEach(fn => { try { fn(); } catch(e) {} });
|
||||
}
|
||||
window._bindSyncCleanups = [];
|
||||
|
||||
let syncInProgress = false;
|
||||
|
||||
// 用于跟踪所有图表的拖动状态 - 在函数内部定义以确保作用域正确
|
||||
let localDragStates = {
|
||||
main: false,
|
||||
volume: false,
|
||||
atr: false,
|
||||
macd: false,
|
||||
chanmacd: false
|
||||
};
|
||||
|
||||
// 同步图表的时间范围
|
||||
function syncCharts(sourceChart, sourceContainer) {
|
||||
if (syncInProgress) return;
|
||||
|
||||
syncInProgress = true;
|
||||
|
||||
try {
|
||||
if (sourceChart && sourceChart.timeScale) {
|
||||
const logicalRange = sourceChart.timeScale().getVisibleLogicalRange();
|
||||
|
||||
if (logicalRange && logicalRange.from !== undefined && logicalRange.to !== undefined) {
|
||||
if (sourceChart !== mainChart && mainChart && mainChart.timeScale) {
|
||||
try { mainChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||||
}
|
||||
if (sourceChart !== volumeChart && volumeChart && volumeChart.timeScale) {
|
||||
try { volumeChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||||
}
|
||||
if (sourceChart !== atrChart && atrChart && atrChart.timeScale) {
|
||||
try { atrChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||||
}
|
||||
if (showMacd && macdChart && sourceChart !== macdChart && macdChart.timeScale) {
|
||||
try { macdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||||
}
|
||||
if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) {
|
||||
try { chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||||
}
|
||||
|
||||
if (tvWidget && tvWidget.state) {
|
||||
tvWidget.state.logicalRange = logicalRange;
|
||||
try { tvWidget.state.visibleRange = sourceChart.timeScale().getVisibleRange(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('同步图表出错:', e);
|
||||
}
|
||||
|
||||
setTimeout(() => { syncInProgress = false; }, 1);
|
||||
}
|
||||
|
||||
// 为每个图表添加事件监听
|
||||
const addChartSyncEvents = (chartContainer, chart) => {
|
||||
const chartType = chart === mainChart ? 'main' :
|
||||
chart === volumeChart ? 'volume' :
|
||||
chart === atrChart ? 'atr' :
|
||||
chart === macdChart ? 'macd' :
|
||||
chart === chanMacdChart ? 'chanmacd' : 'unknown';
|
||||
|
||||
const timeRangeHandler = () => {
|
||||
if (!syncInProgress) {
|
||||
syncCharts(chart, chartContainer);
|
||||
}
|
||||
};
|
||||
chart.timeScale().subscribeVisibleTimeRangeChange(timeRangeHandler);
|
||||
window._bindSyncCleanups.push(() => {
|
||||
try { chart.timeScale().unsubscribeVisibleTimeRangeChange(timeRangeHandler); } catch(e) {}
|
||||
});
|
||||
|
||||
let isScrolling = false;
|
||||
|
||||
const mousedownHandler = () => { localDragStates[chartType] = true; };
|
||||
const mouseupHandler = () => { localDragStates[chartType] = false; };
|
||||
const mouseleaveHandler = () => { localDragStates[chartType] = false; };
|
||||
const wheelHandler = () => {
|
||||
if (!isScrolling) {
|
||||
isScrolling = true;
|
||||
setTimeout(() => {
|
||||
if (!syncInProgress) {
|
||||
syncCharts(chart, chartContainer);
|
||||
}
|
||||
isScrolling = false;
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
|
||||
chartContainer.addEventListener('mousedown', mousedownHandler);
|
||||
chartContainer.addEventListener('mouseup', mouseupHandler);
|
||||
chartContainer.addEventListener('mouseleave', mouseleaveHandler);
|
||||
chartContainer.addEventListener('wheel', wheelHandler);
|
||||
window._bindSyncCleanups.push(() => {
|
||||
chartContainer.removeEventListener('mousedown', mousedownHandler);
|
||||
chartContainer.removeEventListener('mouseup', mouseupHandler);
|
||||
chartContainer.removeEventListener('mouseleave', mouseleaveHandler);
|
||||
chartContainer.removeEventListener('wheel', wheelHandler);
|
||||
});
|
||||
};
|
||||
|
||||
// 添加事件监听
|
||||
if (mainChartContainer && mainChart) {
|
||||
addChartSyncEvents(mainChartContainer, mainChart);
|
||||
}
|
||||
if (volumeChartContainer && volumeChart) {
|
||||
addChartSyncEvents(volumeChartContainer, volumeChart);
|
||||
}
|
||||
if (atrChartContainer && atrChart) {
|
||||
addChartSyncEvents(atrChartContainer, atrChart);
|
||||
}
|
||||
if (showMacd && macdChartContainer && macdChart) {
|
||||
addChartSyncEvents(macdChartContainer, macdChart);
|
||||
}
|
||||
if (showMacd && chanMacdChartContainer && chanMacdChart) {
|
||||
addChartSyncEvents(chanMacdChartContainer, chanMacdChart);
|
||||
}
|
||||
|
||||
// 窗口大小变化时重绘图表 — 使用可清理的方式注册
|
||||
const resizeHandler = () => {
|
||||
if (mainChart && mainChartContainer) {
|
||||
mainChart.applyOptions({ width: mainChartContainer.clientWidth, height: mainChartContainer.clientHeight });
|
||||
}
|
||||
if (volumeChart && volumeChartContainer) {
|
||||
volumeChart.applyOptions({ width: volumeChartContainer.clientWidth, height: volumeChartContainer.clientHeight });
|
||||
}
|
||||
if (atrChart && atrChartContainer) {
|
||||
atrChart.applyOptions({ width: atrChartContainer.clientWidth, height: atrChartContainer.clientHeight });
|
||||
}
|
||||
if (showMacd && macdChart && macdChartContainer) {
|
||||
macdChart.applyOptions({ width: macdChartContainer.clientWidth, height: macdChartContainer.clientHeight });
|
||||
}
|
||||
if (showMacd && chanMacdChart && chanMacdChartContainer) {
|
||||
chanMacdChart.applyOptions({ width: chanMacdChartContainer.clientWidth, height: chanMacdChartContainer.clientHeight });
|
||||
}
|
||||
setTimeout(() => { if (mainChart) syncCharts(mainChart, mainChartContainer); }, 200);
|
||||
};
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
window._bindSyncCleanups.push(() => { window.removeEventListener('resize', resizeHandler); });
|
||||
}
|
||||
function setupTooltip(mainChart, buyMarkers = [], sellMarkers = [], mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, volumeChart, atrChart, macdChart, chanMacdChart, showMacd) {
|
||||
// 清理上一轮 tooltip 的事件订阅
|
||||
if (window._tooltipCleanups) {
|
||||
window._tooltipCleanups.forEach(fn => { try { fn(); } catch(e) {} });
|
||||
}
|
||||
window._tooltipCleanups = [];
|
||||
|
||||
window.debugMode = true;
|
||||
// 初始化 U 显示状态(主/次周期分开控制)
|
||||
const isShowUMain = $('#toggleUOnMain').is(':checked');
|
||||
const isShowUElement = $('#toggleUOnElement').is(':checked');
|
||||
window.showUOnMain = isShowUMain;
|
||||
window.showUOnElement = isShowUElement;
|
||||
if (!isShowUMain && !isShowUElement) {
|
||||
// 隐藏时清空子图上的 U 标记
|
||||
if (tvWidget.series && tvWidget.series.chanMacdLineSeries) {
|
||||
try { tvWidget.series.chanMacdLineSeries.setMarkers([]); } catch (e) {}
|
||||
}
|
||||
if (tvWidget.series && tvWidget.series.chanMacdSignalSeries) {
|
||||
try { tvWidget.series.chanMacdSignalSeries.setMarkers([]); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加买卖点悬浮提示元素
|
||||
const tooltipElement = document.createElement('div');
|
||||
tooltipElement.className = 'point-tooltip';
|
||||
// document.body.appendChild(tooltipElement);
|
||||
|
||||
// 添加自定义十字线信息显示
|
||||
const crosshairTooltip = document.createElement('div');
|
||||
crosshairTooltip.className = 'crosshair-tooltip';
|
||||
crosshairTooltip.style.position = 'absolute';
|
||||
crosshairTooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
||||
crosshairTooltip.style.color = 'white';
|
||||
crosshairTooltip.style.padding = '5px 10px';
|
||||
crosshairTooltip.style.borderRadius = '4px';
|
||||
crosshairTooltip.style.fontSize = '12px';
|
||||
crosshairTooltip.style.zIndex = '1000';
|
||||
crosshairTooltip.style.pointerEvents = 'none';
|
||||
crosshairTooltip.style.display = 'none';
|
||||
// document.body.appendChild(crosshairTooltip);
|
||||
|
||||
// 添加鼠标悬停事件显示提示
|
||||
if (mainChart) {
|
||||
const crosshairHandler = (param) => {
|
||||
// 十字线同步到其他图表 - 通过DOM元素绘制垂直线实现虚线延长效果
|
||||
if (param.time && param.point && volumeChart) {
|
||||
try {
|
||||
// 清除之前的十字线标记
|
||||
const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line');
|
||||
existingVolumeLines.forEach(line => line.remove());
|
||||
const existingAtrLines = document.querySelectorAll('.atr-crosshair-line');
|
||||
existingAtrLines.forEach(line => line.remove());
|
||||
const existingMacdLines = document.querySelectorAll('.macd-crosshair-line');
|
||||
existingMacdLines.forEach(line => line.remove());
|
||||
const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line');
|
||||
existingChanMacdLines.forEach(line => line.remove());
|
||||
|
||||
// 获取时间对应的坐标位置
|
||||
const mainTimeCoordinate = mainChart.timeScale().timeToCoordinate(param.time);
|
||||
if (mainTimeCoordinate !== null) {
|
||||
// 获取主图容器的位置
|
||||
const mainChartRect = mainChartContainer.getBoundingClientRect();
|
||||
|
||||
// 在交易量图上绘制垂直线
|
||||
const volumeTimeCoordinate = volumeChart.timeScale().timeToCoordinate(param.time);
|
||||
if (volumeTimeCoordinate !== null) {
|
||||
const volumeChartRect = volumeChartContainer.getBoundingClientRect();
|
||||
const volumeLine = document.createElement('div');
|
||||
volumeLine.className = 'volume-crosshair-line';
|
||||
volumeLine.style.position = 'fixed'; // 改为fixed定位
|
||||
volumeLine.style.left = (volumeChartRect.left + volumeTimeCoordinate) + 'px';
|
||||
volumeLine.style.top = volumeChartRect.top + 'px';
|
||||
volumeLine.style.width = '1px';
|
||||
volumeLine.style.height = volumeChartRect.height + 'px';
|
||||
volumeLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
|
||||
volumeLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)';
|
||||
volumeLine.style.pointerEvents = 'none';
|
||||
volumeLine.style.zIndex = '1000';
|
||||
document.body.appendChild(volumeLine);
|
||||
}
|
||||
|
||||
// 在ATR图上绘制垂直线
|
||||
if (atrChart && atrChartContainer) {
|
||||
const atrTimeCoordinate = atrChart.timeScale().timeToCoordinate(param.time);
|
||||
if (atrTimeCoordinate !== null) {
|
||||
const atrChartRect = atrChartContainer.getBoundingClientRect();
|
||||
const atrLine = document.createElement('div');
|
||||
atrLine.className = 'atr-crosshair-line';
|
||||
atrLine.style.position = 'fixed'; // 改为fixed定位
|
||||
atrLine.style.left = (atrChartRect.left + atrTimeCoordinate) + 'px';
|
||||
atrLine.style.top = atrChartRect.top + 'px';
|
||||
atrLine.style.width = '1px';
|
||||
atrLine.style.height = atrChartRect.height + 'px';
|
||||
atrLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
|
||||
atrLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)';
|
||||
atrLine.style.pointerEvents = 'none';
|
||||
atrLine.style.zIndex = '1000';
|
||||
document.body.appendChild(atrLine);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有MACD图,也在MACD图上绘制垂直线
|
||||
if (showMacd && macdChart && macdChartContainer) {
|
||||
const macdTimeCoordinate = macdChart.timeScale().timeToCoordinate(param.time);
|
||||
if (macdTimeCoordinate !== null) {
|
||||
const macdChartRect = macdChartContainer.getBoundingClientRect();
|
||||
const macdLine = document.createElement('div');
|
||||
macdLine.className = 'macd-crosshair-line';
|
||||
macdLine.style.position = 'fixed'; // 改为fixed定位
|
||||
macdLine.style.left = (macdChartRect.left + macdTimeCoordinate) + 'px';
|
||||
macdLine.style.top = macdChartRect.top + 'px';
|
||||
macdLine.style.width = '1px';
|
||||
macdLine.style.height = macdChartRect.height + 'px';
|
||||
macdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
|
||||
macdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)';
|
||||
macdLine.style.pointerEvents = 'none';
|
||||
macdLine.style.zIndex = '1000';
|
||||
document.body.appendChild(macdLine);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有ChanMACD图,也在ChanMACD图上绘制垂直线
|
||||
if (showMacd && chanMacdChart && chanMacdChartContainer) {
|
||||
const chanMacdTimeCoordinate = chanMacdChart.timeScale().timeToCoordinate(param.time);
|
||||
if (chanMacdTimeCoordinate !== null) {
|
||||
const chanMacdChartRect = chanMacdChartContainer.getBoundingClientRect();
|
||||
const chanMacdLine = document.createElement('div');
|
||||
chanMacdLine.className = 'chanmacd-crosshair-line';
|
||||
chanMacdLine.style.position = 'fixed';
|
||||
chanMacdLine.style.left = (chanMacdChartRect.left + chanMacdTimeCoordinate) + 'px';
|
||||
chanMacdLine.style.top = chanMacdChartRect.top + 'px';
|
||||
chanMacdLine.style.width = '1px';
|
||||
chanMacdLine.style.height = chanMacdChartRect.height + 'px';
|
||||
chanMacdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
|
||||
chanMacdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)';
|
||||
chanMacdLine.style.pointerEvents = 'none';
|
||||
chanMacdLine.style.zIndex = '1000';
|
||||
document.body.appendChild(chanMacdLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('十字线同步出错:', e);
|
||||
}
|
||||
} else {
|
||||
// 当十字线离开时,清除垂直线
|
||||
try {
|
||||
const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line');
|
||||
existingVolumeLines.forEach(line => line.remove());
|
||||
const existingAtrLines = document.querySelectorAll('.atr-crosshair-line');
|
||||
existingAtrLines.forEach(line => line.remove());
|
||||
const existingMacdLines = document.querySelectorAll('.macd-crosshair-line');
|
||||
existingMacdLines.forEach(line => line.remove());
|
||||
const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line');
|
||||
existingChanMacdLines.forEach(line => line.remove());
|
||||
} catch (e) {
|
||||
console.debug('清除十字线时出错:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (param.time && param.point) {
|
||||
const timeStr = param.time;
|
||||
const markers = [...buyMarkers, ...sellMarkers].filter(m => m.time === timeStr);
|
||||
|
||||
// 同时检查分型标记
|
||||
const fxMarkers = (window.fxMarkers || []).filter(m => m.time === timeStr);
|
||||
const allMarkers = [...markers, ...fxMarkers];
|
||||
|
||||
// 显示时区调试信息
|
||||
if (window.debugMode) {
|
||||
const timezone = $('#timezone').val();
|
||||
const formattedTime = formatTimeWithTimezone(timeStr * 1000, timezone);
|
||||
|
||||
// 获取当前价格 - 通过param.seriesPrices获取
|
||||
let priceInfo = '';
|
||||
if (param.seriesPrices && param.seriesPrices.size > 0) {
|
||||
// 依次从当前可能的主系列中获取价格
|
||||
if (tvWidget.series.candleSeries && param.seriesPrices.get(tvWidget.series.candleSeries)) {
|
||||
const price = param.seriesPrices.get(tvWidget.series.candleSeries);
|
||||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||||
} else if (tvWidget.series.renkoSeries && param.seriesPrices.get(tvWidget.series.renkoSeries)) {
|
||||
const price = param.seriesPrices.get(tvWidget.series.renkoSeries);
|
||||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||||
} else if (tvWidget.series.heikinSeries && param.seriesPrices.get(tvWidget.series.heikinSeries)) {
|
||||
const price = param.seriesPrices.get(tvWidget.series.heikinSeries);
|
||||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||||
} else if (tvWidget.series.barSeries && param.seriesPrices.get(tvWidget.series.barSeries)) {
|
||||
const price = param.seriesPrices.get(tvWidget.series.barSeries);
|
||||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||||
} else if (tvWidget.series.lineSeries && param.seriesPrices.get(tvWidget.series.lineSeries)) {
|
||||
const price = param.seriesPrices.get(tvWidget.series.lineSeries);
|
||||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||||
} else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) {
|
||||
const price = param.seriesPrices.get(tvWidget.series.areaSeries);
|
||||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||||
} else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) {
|
||||
const price = param.seriesPrices.get(tvWidget.series.baselineSeries);
|
||||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||||
}
|
||||
// 如果没有蜡烛图系列价格,尝试从区域图系列获取
|
||||
else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) {
|
||||
const price = param.seriesPrices.get(tvWidget.series.areaSeries);
|
||||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||||
}
|
||||
// 如果没有蜡烛图系列价格,尝试从基线图系列获取
|
||||
else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) {
|
||||
const price = param.seriesPrices.get(tvWidget.series.baselineSeries);
|
||||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 显示自定义时区工具提示,包含价格信息
|
||||
crosshairTooltip.innerHTML = `<div style="font-weight:bold">时间: ${formattedTime}</div>` +
|
||||
(priceInfo ? `<div>${priceInfo}</div>` : '');
|
||||
crosshairTooltip.style.display = 'block';
|
||||
crosshairTooltip.style.left = (param.point.x + 15) + 'px';
|
||||
crosshairTooltip.style.top = (param.point.y - 30) + 'px';
|
||||
}
|
||||
|
||||
if (allMarkers.length > 0) {
|
||||
// 有买卖点或分型标记,显示自定义提示
|
||||
const tooltips = allMarkers.map(m => m.tooltip).join('<br><hr style="margin: 5px 0;">');
|
||||
tooltipElement.innerHTML = tooltips;
|
||||
tooltipElement.style.display = 'block';
|
||||
tooltipElement.style.left = (param.point.x + 15) + 'px';
|
||||
tooltipElement.style.top = (param.point.y + 15) + 'px';
|
||||
} else {
|
||||
// 隐藏提示
|
||||
tooltipElement.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// 隐藏提示
|
||||
tooltipElement.style.display = 'none';
|
||||
crosshairTooltip.style.display = 'none';
|
||||
}
|
||||
};
|
||||
mainChart.subscribeCrosshairMove(crosshairHandler);
|
||||
window._tooltipCleanups.push(() => {
|
||||
try { mainChart.unsubscribeCrosshairMove(crosshairHandler); } catch(e) {}
|
||||
});
|
||||
|
||||
// 处理图表缩放、平移等事件,隐藏提示
|
||||
const hideTooltipHandler = () => {
|
||||
tooltipElement.style.display = 'none';
|
||||
crosshairTooltip.style.display = 'none';
|
||||
};
|
||||
mainChart.timeScale().subscribeVisibleTimeRangeChange(hideTooltipHandler);
|
||||
window._tooltipCleanups.push(() => {
|
||||
try { mainChart.timeScale().unsubscribeVisibleTimeRangeChange(hideTooltipHandler); } catch(e) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:使用指定时区格式化时间戳
|
||||
function formatTimeWithTimezone(timestamp, timezone) {
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString('zh-CN', {
|
||||
timeZone: timezone,
|
||||
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(timestamp).toLocaleString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/* chart_tables.js — split from chart.js */
|
||||
function updateTables(currentData) {
|
||||
// 检查数据有效性
|
||||
if (!currentData) {
|
||||
console.error('updateTables: 传入的数据为空');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = currentData;
|
||||
|
||||
const useSubSubPeriod = $('#subSubPeriodKline').is(':checked');
|
||||
const useElementPeriod = $('#elementPeriodKline').is(':checked');
|
||||
const periodLabel = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期');
|
||||
console.log('数据表显示周期选择:', periodLabel);
|
||||
|
||||
// 笔数据表更新
|
||||
if (tables.bi) {
|
||||
tables.bi.clear().destroy();
|
||||
}
|
||||
|
||||
let biData, biSource;
|
||||
if (useSubSubPeriod && data.sub_sub_bi_list && data.sub_sub_bi_list.length > 0) {
|
||||
biData = data.sub_sub_bi_list;
|
||||
biSource = '次次周期';
|
||||
} else if (useElementPeriod && data.element_bi_list && data.element_bi_list.length > 0) {
|
||||
biData = data.element_bi_list;
|
||||
biSource = '小周期';
|
||||
} else {
|
||||
biData = data.bi_list;
|
||||
biSource = '主周期';
|
||||
}
|
||||
console.log(`表格显示${biSource}笔数据,共${biData ? biData.length : 0}条`);
|
||||
|
||||
tables.bi = $('#biTable').DataTable({
|
||||
data: biData || [],
|
||||
order: [[0, 'desc']],
|
||||
pageLength: 25,
|
||||
columns: [
|
||||
{ data: 'start_time', render: formatTime },
|
||||
{ data: 'end_time', render: formatTime },
|
||||
{ data: 'sure_time', render: formatConfirmTime },
|
||||
{ data: 'start_price', render: formatPrice },
|
||||
{ data: 'end_price', render: formatPrice },
|
||||
{ data: 'direction', render: formatDirection },
|
||||
{ data: 'macd_div', render: formatMacdValue }
|
||||
]
|
||||
});
|
||||
|
||||
// 线段数据表更新
|
||||
if (tables.seg) {
|
||||
tables.seg.clear().destroy();
|
||||
}
|
||||
|
||||
let segData, segSource, uncompletedSegData;
|
||||
if (useSubSubPeriod && data.sub_sub_seg_list && data.sub_sub_seg_list.length > 0) {
|
||||
segData = data.sub_sub_seg_list;
|
||||
uncompletedSegData = data.sub_sub_uncompleted_seg_list || [];
|
||||
segSource = '次次周期';
|
||||
} else if (useElementPeriod && data.element_seg_list && data.element_seg_list.length > 0) {
|
||||
segData = data.element_seg_list;
|
||||
uncompletedSegData = data.element_uncompleted_seg_list || [];
|
||||
segSource = '小周期';
|
||||
} else {
|
||||
segData = data.seg_list;
|
||||
uncompletedSegData = data.uncompleted_seg_list || [];
|
||||
segSource = '主周期';
|
||||
}
|
||||
|
||||
// 合并已完成和未完成的线段数据
|
||||
let allSegData = [];
|
||||
if (segData && segData.length > 0) {
|
||||
allSegData = allSegData.concat(segData.map(seg => ({...seg, status: '已完成'})));
|
||||
}
|
||||
if (uncompletedSegData && uncompletedSegData.length > 0) {
|
||||
allSegData = allSegData.concat(uncompletedSegData.map(seg => ({...seg, status: '未完成'})));
|
||||
}
|
||||
|
||||
console.log(`表格显示${segSource}线段数据,已完成${segData ? segData.length : 0}条,未完成${uncompletedSegData ? uncompletedSegData.length : 0}条,总计${allSegData.length}条`);
|
||||
|
||||
tables.seg = $('#segTable').DataTable({
|
||||
data: allSegData,
|
||||
order: [[0, 'desc']],
|
||||
pageLength: 25,
|
||||
columns: [
|
||||
{ data: 'start_time', render: formatTime },
|
||||
{ data: 'end_time', render: function(data, type, row) {
|
||||
if (data === null || data === undefined) {
|
||||
return type === 'display' ? '<span style="color: red;">未完成</span>' : '';
|
||||
}
|
||||
return formatTime(data, type, row);
|
||||
}},
|
||||
{ data: 'sure_time', render: formatConfirmTime },
|
||||
{ data: 'start_price', render: formatPrice },
|
||||
{ data: 'end_price', render: function(data, type, row) {
|
||||
if (data === null || data === undefined) {
|
||||
return type === 'display' ? '<span style="color: red;">未完成</span>' : '';
|
||||
}
|
||||
return formatPrice(data, type, row);
|
||||
}},
|
||||
{ data: 'direction', render: formatDirection },
|
||||
{ data: 'status', render: function(data, type, row) {
|
||||
if (type === 'display') {
|
||||
const color = data === '已完成' ? 'green' : 'red';
|
||||
return `<span style="color: ${color}; font-weight: bold;">${data}</span>`;
|
||||
}
|
||||
return data;
|
||||
}}
|
||||
]
|
||||
});
|
||||
|
||||
// 中枢数据表更新
|
||||
if (tables.zs) {
|
||||
tables.zs.clear().destroy();
|
||||
}
|
||||
|
||||
let zsData, zsSource;
|
||||
if (useSubSubPeriod && data.sub_sub_zs_list && data.sub_sub_zs_list.length > 0) {
|
||||
zsData = data.sub_sub_zs_list;
|
||||
zsSource = '次次周期';
|
||||
} else if (useElementPeriod && data.element_zs_list && data.element_zs_list.length > 0) {
|
||||
zsData = data.element_zs_list;
|
||||
zsSource = '小周期';
|
||||
} else {
|
||||
zsData = data.zs_list;
|
||||
zsSource = '主周期';
|
||||
}
|
||||
console.log(`表格显示${zsSource}中枢数据,共${zsData ? zsData.length : 0}条`);
|
||||
|
||||
tables.zs = $('#zsTable').DataTable({
|
||||
data: zsData || [],
|
||||
order: [[0, 'desc']],
|
||||
pageLength: 25,
|
||||
columns: [
|
||||
{ data: 'start_time', render: formatTime },
|
||||
{ data: 'end_time', render: formatTime },
|
||||
{ data: 'zg', render: formatPrice },
|
||||
{ data: 'zd', render: formatPrice }
|
||||
]
|
||||
});
|
||||
|
||||
// 买卖点数据表更新
|
||||
if (tables.tradePoints) {
|
||||
tables.tradePoints.clear().destroy();
|
||||
}
|
||||
|
||||
let tradePointsData, tradePointsSource;
|
||||
if (useSubSubPeriod && data.sub_sub_bsp_list && data.sub_sub_bsp_list.length > 0) {
|
||||
tradePointsData = data.sub_sub_bsp_list;
|
||||
tradePointsSource = '次次周期';
|
||||
} else if (useElementPeriod && (data.element_trade_points && data.element_trade_points.length > 0 || data.element_bsp_list && data.element_bsp_list.length > 0)) {
|
||||
tradePointsData = data.element_trade_points || data.element_bsp_list;
|
||||
tradePointsSource = '小周期';
|
||||
} else {
|
||||
tradePointsData = data.trade_points || data.bsp_list;
|
||||
tradePointsSource = '主周期';
|
||||
}
|
||||
console.log(`表格显示${tradePointsSource}买卖点数据,共${tradePointsData ? tradePointsData.length : 0}条`);
|
||||
|
||||
tables.tradePoints = $('#tradePointsTable').DataTable({
|
||||
data: tradePointsData || [],
|
||||
order: [[0, 'desc']],
|
||||
pageLength: 25,
|
||||
columns: [
|
||||
{ data: 'time', render: formatTime },
|
||||
{ data: 'price', render: formatPrice },
|
||||
{ data: 'type', render: formatTradePointType },
|
||||
{ data: 'desc' }
|
||||
]
|
||||
});
|
||||
|
||||
// 更新数据源信息显示
|
||||
const selectedPeriod = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期');
|
||||
const timeframe = useSubSubPeriod && data.sub_sub_timeframe ? data.sub_sub_timeframe : (useElementPeriod && data.element_timeframe ? data.element_timeframe : $('#timeframe').val());
|
||||
$('#dataSourceText').html(`当前显示的是<strong>${selectedPeriod} (${timeframe})</strong> 数据`);
|
||||
|
||||
// K线数据表更新
|
||||
if (tables.kline) {
|
||||
tables.kline.clear().destroy();
|
||||
}
|
||||
|
||||
// 根据用户选择决定使用哪个周期的K线数据
|
||||
let klineData, klineSource;
|
||||
if (useSubSubPeriod && data.sub_sub_kline_data && data.sub_sub_kline_data.length > 0) {
|
||||
klineData = data.sub_sub_kline_data;
|
||||
klineSource = '次次周期';
|
||||
} else if (useElementPeriod && data.element_kline_data && data.element_kline_data.length > 0) {
|
||||
klineData = data.element_kline_data;
|
||||
klineSource = '小周期';
|
||||
} else {
|
||||
klineData = data.kline_data;
|
||||
klineSource = '主周期';
|
||||
}
|
||||
console.log(`表格显示${klineSource}K线数据,共${klineData ? klineData.length : 0}条`);
|
||||
|
||||
tables.kline = $('#klineTable').DataTable({
|
||||
data: klineData || [],
|
||||
order: [[0, 'desc']],
|
||||
pageLength: 25,
|
||||
columns: [
|
||||
{ data: 'date', render: function(data) { return formatTime(data); } },
|
||||
{ data: 'open', render: formatPrice },
|
||||
{ data: 'high', render: formatPrice },
|
||||
{ data: 'low', render: formatPrice },
|
||||
{ data: 'close', render: formatPrice },
|
||||
{ data: 'volume', render: function(data) { return parseInt(data).toLocaleString(); } }
|
||||
]
|
||||
});
|
||||
|
||||
// 未完成中枢数据表更新
|
||||
if (tables.uncompletedZs) {
|
||||
tables.uncompletedZs.clear().destroy();
|
||||
}
|
||||
|
||||
let uncompletedZsData, uncompletedZsSource;
|
||||
if (useSubSubPeriod && data.sub_sub_uncompleted_zs_list && data.sub_sub_uncompleted_zs_list.length > 0) {
|
||||
uncompletedZsData = data.sub_sub_uncompleted_zs_list;
|
||||
uncompletedZsSource = '次次周期';
|
||||
} else if (useElementPeriod && data.element_uncompleted_zs_list && data.element_uncompleted_zs_list.length > 0) {
|
||||
uncompletedZsData = data.element_uncompleted_zs_list;
|
||||
uncompletedZsSource = '小周期';
|
||||
} else {
|
||||
uncompletedZsData = data.uncompleted_zs_list;
|
||||
uncompletedZsSource = '主周期';
|
||||
}
|
||||
console.log(`表格显示${uncompletedZsSource}未完成中枢数据,共${uncompletedZsData ? uncompletedZsData.length : 0}条`);
|
||||
|
||||
tables.uncompletedZs = $('#uncompletedZsTable').DataTable({
|
||||
data: uncompletedZsData || [],
|
||||
order: [[0, 'desc']],
|
||||
pageLength: 25,
|
||||
columns: [
|
||||
{ data: 'start_time', render: formatTime },
|
||||
{ data: 'zg', render: formatPrice },
|
||||
{ data: 'zd', render: formatPrice }
|
||||
]
|
||||
});
|
||||
|
||||
// MACD数据表更新
|
||||
if (tables.macd) {
|
||||
tables.macd.clear().destroy();
|
||||
}
|
||||
|
||||
// 根据用户选择决定使用哪个周期的MACD数据
|
||||
let macdDisplayData = [];
|
||||
let macdSource;
|
||||
if (useElementPeriod && data.element_kline_data && data.element_macd) {
|
||||
// 使用小周期数据
|
||||
macdSource = '小周期';
|
||||
macdDisplayData = data.element_kline_data.map((item, index) => {
|
||||
return {
|
||||
time: item.date,
|
||||
close: item.close,
|
||||
macd: data.element_macd.macd[index],
|
||||
signal: data.element_macd.signal[index],
|
||||
histogram: data.element_macd.histogram[index]
|
||||
};
|
||||
});
|
||||
} else if (data.kline_data && data.macd) {
|
||||
// 使用主周期数据
|
||||
macdSource = '主周期';
|
||||
macdDisplayData = 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]
|
||||
};
|
||||
});
|
||||
}
|
||||
console.log(`表格显示${macdSource}MACD数据,共${macdDisplayData.length}条`);
|
||||
|
||||
tables.macd = $('#macdTable').DataTable({
|
||||
data: macdDisplayData,
|
||||
order: [[0, 'desc']],
|
||||
pageLength: 25,
|
||||
columns: [
|
||||
{ data: 'time', render: formatTime },
|
||||
{ data: 'close', render: formatPrice },
|
||||
{ data: 'macd', render: formatMacdValue },
|
||||
{ data: 'signal', render: formatMacdValue },
|
||||
{ data: 'histogram', render: formatMacdValue }
|
||||
]
|
||||
});
|
||||
|
||||
// 更新数据源信息
|
||||
setupDataSourceInfo(data);
|
||||
}
|
||||
// 设置数据源信息显示
|
||||
function setupDataSourceInfo(data) {
|
||||
const useSubSubPeriod = $('#subSubPeriodKline').is(':checked');
|
||||
const useElementPeriod = $('#elementPeriodKline').is(':checked');
|
||||
const mainTimeframe = $('#timeframe').val();
|
||||
const elementTimeframe = data.element_timeframe || mainTimeframe;
|
||||
const subSubTimeframe = data.sub_sub_timeframe || elementTimeframe;
|
||||
|
||||
$('#kline-tab, #macd-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_kline_data && data.sub_sub_kline_data.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 数据`);
|
||||
} else if (useElementPeriod && data.element_kline_data && data.element_kline_data.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 数据`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#bi-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_bi_list && data.sub_sub_bi_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 笔数据`);
|
||||
} else if (useElementPeriod && data.element_bi_list && data.element_bi_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 笔数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 笔数据`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#seg-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_seg_list && data.sub_sub_seg_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 线段数据`);
|
||||
} else if (useElementPeriod && data.element_seg_list && data.element_seg_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 线段数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 线段数据`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#zs-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_zs_list && data.sub_sub_zs_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 中枢数据`);
|
||||
} else if (useElementPeriod && data.element_zs_list && data.element_zs_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 中枢数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 中枢数据`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#trade-points-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_bsp_list && data.sub_sub_bsp_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 买卖点数据`);
|
||||
} else if (useElementPeriod && data.element_trade_points && data.element_trade_points.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 买卖点数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 买卖点数据`);
|
||||
}
|
||||
});
|
||||
|
||||
// 初始触发当前标签的点击事件
|
||||
$('.nav-link.active').trigger('click');
|
||||
}
|
||||
|
||||
// 获取可用交易对
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
/* chart_view.js — split from chart.js */
|
||||
function updateChart() {
|
||||
// 只显示旋转加载图标
|
||||
$('#refreshLoadingSpinner').show();
|
||||
|
||||
// 获取参数
|
||||
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() || window.DEFAULT_MAIN_TIMEFRAME || '5m';
|
||||
const timezone = $('#timezone').val() || 'Asia/Shanghai';
|
||||
const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m';
|
||||
const subSubTimeframe = $('#subSubTimeframe').val() || '';
|
||||
|
||||
// 确保时区参数有效
|
||||
console.log('更新图表使用时区:', timezone);
|
||||
console.log('数据源:', dataSource, '交易对/股票:', symbol);
|
||||
|
||||
// 如果symbol为空,不发送请求
|
||||
if (!symbol) {
|
||||
console.error('交易对/股票代码不能为空');
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`更新图表: symbol=${symbol}, timeframe=${timeframe}, elementTimeframe=${elementTimeframe}, timezone=${timezone}`);
|
||||
|
||||
// 获取开始和结束时间(如果已设置)
|
||||
let startTimeMs = null;
|
||||
let endTimeMs = null;
|
||||
|
||||
if ($('#start_time').val()) {
|
||||
startTimeMs = new Date($('#start_time').val()).getTime();
|
||||
}
|
||||
|
||||
if ($('#end_time').val()) {
|
||||
endTimeMs = new Date($('#end_time').val()).getTime();
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const requestId = ++lastRequestId; // 标记本次请求
|
||||
$.ajax({
|
||||
url: '/api/analyze',
|
||||
data: {
|
||||
symbol: symbol,
|
||||
timeframe: timeframe,
|
||||
timezone: timezone,
|
||||
element_timeframe: elementTimeframe,
|
||||
sub_sub_timeframe: subSubTimeframe || undefined,
|
||||
start_time: startTimeMs,
|
||||
end_time: endTimeMs,
|
||||
elements_only: false,
|
||||
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
|
||||
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0
|
||||
},
|
||||
success: function(data) {
|
||||
// 隐藏加载图标
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
|
||||
// 忽略过期响应
|
||||
if (requestId !== lastRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存当前数据
|
||||
if (currentData) {
|
||||
// 覆盖前断开旧引用,帮助GC尽快回收
|
||||
delete currentData.original_kline_data;
|
||||
delete currentData.original_macd;
|
||||
}
|
||||
currentData = data;
|
||||
|
||||
refreshChart(data);
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
// 隐藏加载图标
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
|
||||
// 显示错误信息
|
||||
console.error('加载数据失败:', errorThrown);
|
||||
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
|
||||
}
|
||||
});
|
||||
}
|
||||
function captureChartViewState(chart) {
|
||||
if (!chart || !chart.timeScale) return null;
|
||||
const ts = chart.timeScale();
|
||||
const tsOptions = ts.options ? ts.options() : {};
|
||||
return {
|
||||
barSpacing: tsOptions.barSpacing,
|
||||
rightOffset: tsOptions.rightOffset,
|
||||
scrollPosition: ts.scrollPosition ? ts.scrollPosition() : null,
|
||||
visibleRange: ts.getVisibleRange ? ts.getVisibleRange() : null,
|
||||
logicalRange: ts.getVisibleLogicalRange ? ts.getVisibleLogicalRange() : null
|
||||
};
|
||||
}
|
||||
|
||||
function restoreChartViewState(charts, viewState) {
|
||||
if (!viewState || !Array.isArray(charts) || charts.length === 0) return;
|
||||
const validCharts = charts.filter(c => c && c.timeScale);
|
||||
if (validCharts.length === 0) return;
|
||||
|
||||
validCharts.forEach(c => {
|
||||
try {
|
||||
const optionsPatch = {};
|
||||
if (typeof viewState.barSpacing === 'number') optionsPatch.barSpacing = viewState.barSpacing;
|
||||
if (typeof viewState.rightOffset === 'number') optionsPatch.rightOffset = viewState.rightOffset;
|
||||
if (Object.keys(optionsPatch).length) {
|
||||
c.timeScale().applyOptions(optionsPatch);
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
let restored = false;
|
||||
|
||||
// 优先按逻辑范围恢复(对新数据更稳健)
|
||||
if (viewState.logicalRange && viewState.logicalRange.from !== undefined && viewState.logicalRange.to !== undefined) {
|
||||
validCharts.forEach(c => {
|
||||
try {
|
||||
c.timeScale().setVisibleLogicalRange(viewState.logicalRange);
|
||||
restored = true;
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// 逻辑范围失败时,回退到时间可见范围
|
||||
if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) {
|
||||
validCharts.forEach(c => {
|
||||
try {
|
||||
c.timeScale().setVisibleRange(viewState.visibleRange);
|
||||
restored = true;
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// 最后回退到滚动位置
|
||||
if (!restored && typeof viewState.scrollPosition === 'number') {
|
||||
validCharts.forEach(c => {
|
||||
try { c.timeScale().scrollToPosition(viewState.scrollPosition, false); } catch (e) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
// 初始化图表
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* TradingView Datafeed — 对接 Data Provider 微服务
|
||||
*
|
||||
* 数据源: http://103.179.242.166
|
||||
* - GET /timeframes → 可用周期
|
||||
* - GET /api/candles → 历史 OHLCV
|
||||
* - WS /ws → 实时 K 线推送
|
||||
*
|
||||
* 实现 IDatafeedChartApi 核心接口:
|
||||
* onReady, resolveSymbol, getBars, subscribeBars, unsubscribeBars
|
||||
*/
|
||||
|
||||
var ChanTVDatafeed = (function () {
|
||||
'use strict'
|
||||
|
||||
// 默认 data_provider 地址,可通过 URL param 覆盖
|
||||
var DATA_HOST = 'http://103.179.242.166'
|
||||
|
||||
// ---- resolution <-> timeframe 转换 ----
|
||||
var RES_TO_TF = {
|
||||
'1': '1m', '3': '3m', '5': '5m', '10': '10m', '15': '15m', '30': '30m',
|
||||
'60': '1h', '120': '2h', '240': '4h', '360': '6h', '480': '8h',
|
||||
'720': '12h',
|
||||
'D': '1d', '1D': '1d',
|
||||
'3D': '3d',
|
||||
'W': '1w', '1W': '1w',
|
||||
'M': '1M', '1M': '1M',
|
||||
}
|
||||
|
||||
function resToTf(resolution) {
|
||||
var r = String(resolution)
|
||||
return RES_TO_TF[r] || r
|
||||
}
|
||||
|
||||
// ---- WebSocket 管理 ----
|
||||
var ws = null
|
||||
var wsReconnectTimer = null
|
||||
var wsSubs = {} // listenerGuid -> { symbol, tf, onTick, lastTickTime }
|
||||
var wsUrl = DATA_HOST.replace(/^http/, 'ws') + '/ws'
|
||||
|
||||
function wsConnect() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl)
|
||||
} catch (e) {
|
||||
console.warn('[TV Datafeed] WS 连接失败', e)
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
ws.onopen = function () {
|
||||
console.log('[TV Datafeed] WS 已连接')
|
||||
// 重新订阅
|
||||
Object.keys(wsSubs).forEach(function (guid) {
|
||||
var sub = wsSubs[guid]
|
||||
sendWS({ action: 'subscribe', symbol: sub.symbol, timeframe: sub.tf })
|
||||
})
|
||||
}
|
||||
|
||||
ws.onmessage = function (evt) {
|
||||
try {
|
||||
var msg = JSON.parse(evt.data)
|
||||
var bars = msg.data || msg.bars // data_provider 用 'data' 字段
|
||||
if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) {
|
||||
// 只推送最新一根 bar,避免历史快照造成时间顺序冲突
|
||||
// 按时间升序排列取最后一个
|
||||
var sorted = bars.slice().sort(function (a, b) { return (a.timestamp || 0) - (b.timestamp || 0) })
|
||||
var latest = sorted[sorted.length - 1]
|
||||
// 广播给所有匹配的 subscriber
|
||||
Object.keys(wsSubs).forEach(function (guid) {
|
||||
var sub = wsSubs[guid]
|
||||
if (sub.symbol === msg.symbol && sub.tf === msg.timeframe) {
|
||||
// 跳过已处理过的时间戳
|
||||
if (sub.lastTickTime && latest.timestamp <= sub.lastTickTime) return
|
||||
try {
|
||||
sub.onTick({
|
||||
time: latest.timestamp,
|
||||
open: latest.open,
|
||||
high: latest.high,
|
||||
low: latest.low,
|
||||
close: latest.close,
|
||||
volume: latest.volume,
|
||||
})
|
||||
sub.lastTickTime = latest.timestamp
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = function () {
|
||||
console.log('[TV Datafeed] WS 断开')
|
||||
ws = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = function () {
|
||||
// onclose 会跟着触发
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (wsReconnectTimer) return
|
||||
wsReconnectTimer = setTimeout(function () {
|
||||
wsReconnectTimer = null
|
||||
wsConnect()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function sendWS(data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Datafeed API ----
|
||||
|
||||
/**
|
||||
* 主配置:返回支持的 resolutions、exchanges 等
|
||||
*/
|
||||
function onReady(callback) {
|
||||
// 使用固定 resolutions(避免 /timeframes 502 阻塞初始化)
|
||||
var supported = ['1', '5', '15', '30', '60', '120', '240', 'D', 'W']
|
||||
console.log('[TV Datafeed] onReady — supported_resolutions:', supported)
|
||||
|
||||
setTimeout(function () {
|
||||
callback({
|
||||
supported_resolutions: supported,
|
||||
supports_marks: false,
|
||||
supports_timescale_marks: false,
|
||||
supports_time: true,
|
||||
exchanges: [{ value: 'BINANCE', name: 'Binance', desc: 'Binance Futures' }],
|
||||
symbols_types: [{ name: 'Crypto', value: 'crypto' }],
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 symbol:'BINANCE:BTC/USDT:USDT' → 分离 exchange 和 symbol
|
||||
*/
|
||||
function resolveSymbol(symbolName, onResolve, onError) {
|
||||
var name = String(symbolName)
|
||||
var exchange = 'BINANCE'
|
||||
var symbol = name
|
||||
|
||||
// 解析 EXCHANGE:SYMBOL 格式
|
||||
// 如果第一段不含 '/',就是交易所名;否则整串就是 symbol
|
||||
// 例: 'BINANCE:BTC/USDT:USDT' → exchange=BINANCE, sym=BTC/USDT:USDT
|
||||
// 'BTC/USDT:USDT' → exchange=BINANCE, sym=BTC/USDT:USDT
|
||||
// 'BTC/USDT' → exchange=BINANCE, sym=BTC/USDT:USDT
|
||||
var firstColon = name.indexOf(':')
|
||||
if (firstColon >= 0) {
|
||||
var prefix = name.substring(0, firstColon)
|
||||
if (prefix.indexOf('/') === -1) {
|
||||
// 第一段是交易所名(如 'BINANCE')
|
||||
exchange = prefix
|
||||
symbol = name.substring(firstColon + 1)
|
||||
}
|
||||
// 否则第一段含 '/'(如 'BTC/USDT'),整串就是 symbol
|
||||
}
|
||||
|
||||
// data_provider 用 BTC/USDT:USDT 格式(需要 :USDT 后缀)
|
||||
var dpSymbol = symbol
|
||||
if (dpSymbol.indexOf(':USDT') === -1 && dpSymbol.indexOf('/USDT') >= 0) {
|
||||
dpSymbol = dpSymbol + ':USDT'
|
||||
}
|
||||
|
||||
console.log('[TV Datafeed] resolveSymbol', name, '→ exchange:', exchange, 'symbol:', symbol, 'dp:', dpSymbol)
|
||||
|
||||
// TV 要求异步回调(setTimeout 0)
|
||||
setTimeout(function () {
|
||||
onResolve({
|
||||
name: name,
|
||||
ticker: name,
|
||||
description: symbol,
|
||||
exchange: exchange,
|
||||
type: 'crypto',
|
||||
session: '24x7',
|
||||
timezone: 'Asia/Shanghai',
|
||||
minmov: 1,
|
||||
pricescale: 100,
|
||||
has_intraday: true,
|
||||
has_seconds: false,
|
||||
has_daily: true,
|
||||
has_weekly_and_monthly: true,
|
||||
supported_resolutions: ['1', '5', '15', '30', '60', '120', '240', 'D', 'W'],
|
||||
intraday_multipliers: ['1', '5', '15', '30', '60', '120', '240'],
|
||||
volume_precision: 2,
|
||||
_dpSymbol: dpSymbol,
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史 bars
|
||||
*/
|
||||
function getBars(symbolInfo, resolution, periodParams, onResult, onError) {
|
||||
var tf = resToTf(resolution)
|
||||
var symbol = symbolInfo._dpSymbol || symbolInfo.ticker.split(':').slice(1).join(':')
|
||||
// 确保 symbol 是 data_provider 格式
|
||||
if (symbol.indexOf(':USDT') === -1 && symbol.indexOf('/USDT') >= 0) {
|
||||
symbol = symbol + ':USDT'
|
||||
}
|
||||
|
||||
var params = 'symbol=' + encodeURIComponent(symbol) + '&tf=' + encodeURIComponent(tf)
|
||||
|
||||
// periodParams.from / to 是秒,data_provider 需要毫秒
|
||||
if (periodParams.from) {
|
||||
params += '&start=' + (periodParams.from * 1000)
|
||||
}
|
||||
if (periodParams.to) {
|
||||
params += '&end=' + (periodParams.to * 1000)
|
||||
}
|
||||
if (periodParams.firstDataRequest) {
|
||||
// 首次请求多取一些数据供缠论计算
|
||||
params += '&limit=1000'
|
||||
}
|
||||
|
||||
var url = DATA_HOST + '/api/candles?' + params
|
||||
console.log('[TV Datafeed] getBars', symbol, tf, '→', url)
|
||||
|
||||
fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status)
|
||||
return r.json()
|
||||
})
|
||||
.then(function (data) {
|
||||
console.log('[TV Datafeed] getBars 返回', data.length, '条')
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
onResult([], { noData: true })
|
||||
return
|
||||
}
|
||||
|
||||
// 按时间升序排列并去重,避免跨请求重叠导致时间顺序冲突
|
||||
var seen = {}
|
||||
var bars = []
|
||||
data.forEach(function (d) {
|
||||
if (!seen[d.timestamp]) {
|
||||
seen[d.timestamp] = true
|
||||
bars.push({
|
||||
time: d.timestamp, // ms
|
||||
open: d.open,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
close: d.close,
|
||||
volume: d.volume,
|
||||
})
|
||||
}
|
||||
})
|
||||
bars.sort(function (a, b) { return a.time - b.time })
|
||||
|
||||
// 传 noData: false 表示还有更多历史数据
|
||||
onResult(bars, { noData: false })
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error('[TV Datafeed] getBars 失败', err)
|
||||
onError(err.message || '获取数据失败')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅实时数据(通过 WebSocket)
|
||||
*/
|
||||
function subscribeBars(symbolInfo, resolution, onTick, listenerGuid) {
|
||||
var tf = resToTf(resolution)
|
||||
var symbol = symbolInfo._dpSymbol || symbolInfo.ticker.split(':').slice(1).join(':')
|
||||
if (symbol.indexOf(':USDT') === -1 && symbol.indexOf('/USDT') >= 0) {
|
||||
symbol = symbol + ':USDT'
|
||||
}
|
||||
|
||||
wsSubs[listenerGuid] = { symbol: symbol, tf: tf, onTick: onTick }
|
||||
|
||||
// 确保 WS 已连接
|
||||
wsConnect()
|
||||
|
||||
// 如果已连接,立即订阅
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
sendWS({ action: 'subscribe', symbol: symbol, timeframe: tf })
|
||||
}
|
||||
// 否则等 WS onopen 时会重新订阅所有
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
*/
|
||||
function unsubscribeBars(listenerGuid) {
|
||||
var sub = wsSubs[listenerGuid]
|
||||
if (sub) {
|
||||
sendWS({ action: 'unsubscribe', symbol: sub.symbol, timeframe: sub.tf })
|
||||
delete wsSubs[listenerGuid]
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 导出 ----
|
||||
return {
|
||||
onReady: onReady,
|
||||
resolveSymbol: resolveSymbol,
|
||||
getBars: getBars,
|
||||
subscribeBars: subscribeBars,
|
||||
unsubscribeBars: unsubscribeBars,
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,258 @@
|
||||
/* macd_ui.js */
|
||||
function showMacdConfig() {
|
||||
$.get('/api/macd_config', function(data) {
|
||||
$('#macdFastPeriod').val(data.fast);
|
||||
$('#macdSlowPeriod').val(data.slow);
|
||||
$('#macdSignalPeriod').val(data.signal);
|
||||
$('#macdConfigModal').css('display', 'flex');
|
||||
});
|
||||
}
|
||||
|
||||
function hideMacdConfig() {
|
||||
$('#macdConfigModal').css('display', 'none');
|
||||
}
|
||||
|
||||
function resetMacdConfig() {
|
||||
$('#macdFastPeriod').val(24);
|
||||
$('#macdSlowPeriod').val(52);
|
||||
$('#macdSignalPeriod').val(9);
|
||||
}
|
||||
|
||||
function saveMacdConfig() {
|
||||
const fast = parseInt($('#macdFastPeriod').val());
|
||||
const slow = parseInt($('#macdSlowPeriod').val());
|
||||
const signal = parseInt($('#macdSignalPeriod').val());
|
||||
if (fast >= slow) {
|
||||
alert('快线周期必须小于慢线周期');
|
||||
return;
|
||||
}
|
||||
if (fast < 2 || slow < 2 || signal < 2) {
|
||||
alert('周期值必须大于等于2');
|
||||
return;
|
||||
}
|
||||
$.ajax({
|
||||
url: '/api/macd_config',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ fast: fast, slow: slow, signal: signal }),
|
||||
success: function() {
|
||||
hideMacdConfig();
|
||||
updateChart();
|
||||
},
|
||||
error: function() {
|
||||
alert('保存MACD参数失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(document).on('click', '#macdConfigModal', function(e) {
|
||||
if (e.target === this) hideMacdConfig();
|
||||
});
|
||||
|
||||
// 添加原始K线复选框变更事件
|
||||
$('#showOriginalKline').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 添加K线形态下拉变更事件(同步隐藏的原始K线开关并重绘)
|
||||
$('#klineType').change(function() {
|
||||
const type = $(this).val();
|
||||
$('#showOriginalKline').prop('checked', type === 'candlestick');
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 添加笔复选框变更事件
|
||||
$('#showMainBi').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 添加线段复选框变更事件
|
||||
$('#showMainSeg').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 添加中枢复选框变更事件
|
||||
$('#showMainZs').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
// 添加主周期BI中枢复选框变更事件(委托绑定,避免DOM更新后失效)
|
||||
console.log('初始化BI中枢事件绑定');
|
||||
$(document).on('change', '#showMainBiZs', function() {
|
||||
console.log('主BI中枢切换为:', $('#showMainBiZs').is(':checked'));
|
||||
updateChartDisplay();
|
||||
});
|
||||
// 结构价值区复选框变更事件
|
||||
$(document).on('change', '#showMainStructureZone', function() {
|
||||
const on = $('#showMainStructureZone').is(':checked');
|
||||
console.log('结构区切换为:', on);
|
||||
// 勾选后才向服务器请求多周期结构区数据;取消勾选仅重绘,不重复拉取
|
||||
if (on) {
|
||||
updateChart();
|
||||
} else {
|
||||
updateChartDisplay();
|
||||
}
|
||||
});
|
||||
|
||||
// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图
|
||||
$('#showMainTrend').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
$('#showElementTrend').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 添加买卖点复选框变更事件
|
||||
$('#showElementBi').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 添加线段复选框变更事件
|
||||
$('#showElementSeg').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 添加中枢复选框变更事件
|
||||
$('#showElementZs').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
// 添加次周期BI中枢复选框变更事件(委托绑定,避免DOM更新后失效)
|
||||
$(document).on('change', '#showElementBiZs', function() {
|
||||
console.log('次BI中枢切换为:', $('#showElementBiZs').is(':checked'));
|
||||
updateChartDisplay();
|
||||
});
|
||||
// 次次周期显示开关变更事件
|
||||
$('#showSubSubBi, #showSubSubSeg, #showSubSubZs, #showSubSubBiZs, #showSubSubKlcFxType, #showSubSubTrend, #showSubSubBsp').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
$(document).on('change', '#toggleUOnSubSub', function() {
|
||||
window.showUOnSubSub = $('#toggleUOnSubSub').is(':checked');
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 买卖点复选框已移除
|
||||
|
||||
// 趋势开关已移除
|
||||
|
||||
// 添加K线周期切换事件监听器
|
||||
$('input[name="klinePeriod"]').change(function() {
|
||||
console.log('K线周期切换:', $(this).attr('id'), $(this).is(':checked'));
|
||||
updateChartDisplay();
|
||||
|
||||
// 更新数据源信息
|
||||
if (currentData) {
|
||||
setupDataSourceInfo(currentData);
|
||||
}
|
||||
});
|
||||
|
||||
// 当选择不同的元素时间周期时
|
||||
$('#elementTimeframe').change(function() {
|
||||
const elementTimeframe = $(this).val();
|
||||
const mainTimeframe = $('#timeframe').val();
|
||||
|
||||
// 检查选择的元素时间周期是否小于等于主周期
|
||||
if (compareTimeframes(elementTimeframe, mainTimeframe) > 0) {
|
||||
alert('元素时间周期必须小于或等于主图表时间周期。');
|
||||
setSmallerOrEqualTimeframe(); // 重置为最大的小于等于时间周期
|
||||
return;
|
||||
}
|
||||
// 次次周期必须小于等于次周期
|
||||
ensureSubSubLteElement();
|
||||
console.log(`当前选择的元素时间周期: ${elementTimeframe},需要点击分析按钮来应用更改`);
|
||||
});
|
||||
// 次次周期变更时校验 <= 次周期
|
||||
$('#subSubTimeframe').change(function() {
|
||||
const subSub = $(this).val();
|
||||
const elementTf = $('#elementTimeframe').val();
|
||||
if (compareTimeframes(subSub, elementTf) > 0) {
|
||||
alert('次次周期必须小于或等于次周期。');
|
||||
ensureSubSubLteElement();
|
||||
return;
|
||||
}
|
||||
});
|
||||
function ensureSubSubLteElement() {
|
||||
const timeframes = window.AVAILABLE_TIMEFRAMES || [];
|
||||
const elementTf = $('#elementTimeframe').val();
|
||||
const subSubTf = $('#subSubTimeframe').val();
|
||||
if (compareTimeframes(subSubTf, elementTf) > 0) {
|
||||
const idxEl = timeframes.indexOf(elementTf);
|
||||
const validSubSub = idxEl > 0 ? timeframes[idxEl - 1] : timeframes[0];
|
||||
$('#subSubTimeframe').val(validSubSub || elementTf);
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用 /api/chart_metadata 返回的周期列表(切换 crypto / A股 时拉取) */
|
||||
function applyChartMetadata(meta) {
|
||||
if (!meta || meta.error || !Array.isArray(meta.timeframe_keys) || meta.timeframe_keys.length === 0) {
|
||||
return;
|
||||
}
|
||||
window.AVAILABLE_TIMEFRAMES = meta.timeframe_keys;
|
||||
window.DEFAULT_MAIN_TIMEFRAME = meta.default_main;
|
||||
window.DEFAULT_ELEMENT_TIMEFRAME = meta.default_element;
|
||||
window.DEFAULT_SUB_SUB_TIMEFRAME = meta.default_sub_sub;
|
||||
const labels = meta.timeframes || {};
|
||||
function refill(selId, preferredVal) {
|
||||
const $el = $(selId);
|
||||
const cur = $el.val();
|
||||
$el.empty();
|
||||
meta.timeframe_keys.forEach(function(k) {
|
||||
$el.append($('<option>', { value: k, text: labels[k] || k }));
|
||||
});
|
||||
const pick = (cur && meta.timeframe_keys.indexOf(cur) >= 0) ? cur : preferredVal;
|
||||
if (pick && meta.timeframe_keys.indexOf(pick) >= 0) {
|
||||
$el.val(pick);
|
||||
} else {
|
||||
$el.val(meta.timeframe_keys[0]);
|
||||
}
|
||||
}
|
||||
refill('#timeframe', meta.default_main);
|
||||
refill('#elementTimeframe', meta.default_element);
|
||||
refill('#subSubTimeframe', meta.default_sub_sub);
|
||||
const mainTf = $('#timeframe').val();
|
||||
if (compareTimeframes($('#elementTimeframe').val(), mainTf) > 0) {
|
||||
setSmallestLargerTimeframe(mainTf);
|
||||
}
|
||||
ensureSubSubLteElement();
|
||||
}
|
||||
|
||||
// 比较两个时间周期的大小
|
||||
function compareTimeframes(tf1, tf2) {
|
||||
const v1 = window.timeframeToMs(tf1);
|
||||
const v2 = window.timeframeToMs(tf2);
|
||||
if (v1 === null || v2 === null) {
|
||||
return 0;
|
||||
}
|
||||
return v1 - v2;
|
||||
}
|
||||
|
||||
// 设置比主周期小的最大周期
|
||||
function setSmallestLargerTimeframe(mainTimeframe) {
|
||||
const timeframes = window.AVAILABLE_TIMEFRAMES || [];
|
||||
const mainIndex = timeframes.indexOf(mainTimeframe);
|
||||
|
||||
if (mainIndex > 0) {
|
||||
$('#elementTimeframe').val(timeframes[mainIndex - 1]);
|
||||
} else {
|
||||
$('#elementTimeframe').val(timeframes[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置小于或等于主周期的时间周期
|
||||
function setSmallerOrEqualTimeframe(mainTimeframe) {
|
||||
const timeframes = window.AVAILABLE_TIMEFRAMES || [];
|
||||
const mainIndex = timeframes.indexOf(mainTimeframe);
|
||||
|
||||
// 默认选择相同的时间周期
|
||||
$('#elementTimeframe').val(mainTimeframe);
|
||||
}
|
||||
|
||||
// 当主时间周期变更时,确保分形元素时间周期、次次周期正确
|
||||
$('#timeframe').change(function() {
|
||||
const mainTimeframe = $(this).val();
|
||||
const elementTimeframe = $('#elementTimeframe').val();
|
||||
|
||||
if (compareTimeframes(elementTimeframe, mainTimeframe) > 0) {
|
||||
setSmallerOrEqualTimeframe(mainTimeframe);
|
||||
}
|
||||
ensureSubSubLteElement();
|
||||
});
|
||||
let _lastKlinePeriod = 'main';
|
||||
@@ -0,0 +1,443 @@
|
||||
/* main.js */
|
||||
$(document).ready(function() {
|
||||
// 初始化技术指标下拉菜单
|
||||
initIndicatorDropdown();
|
||||
|
||||
// 设置默认的筛选时间(最近7天)
|
||||
const now = new Date();
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// 添加页面滚动事件监听器,清除十字线延长线
|
||||
$(window).on('scroll', function() {
|
||||
try {
|
||||
// 清除所有十字线延长线,防止它们跟着页面滚动
|
||||
const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line');
|
||||
existingVolumeLines.forEach(line => line.remove());
|
||||
const existingAtrLines = document.querySelectorAll('.atr-crosshair-line');
|
||||
existingAtrLines.forEach(line => line.remove());
|
||||
const existingMacdLines = document.querySelectorAll('.macd-crosshair-line');
|
||||
existingMacdLines.forEach(line => line.remove());
|
||||
const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line');
|
||||
existingChanMacdLines.forEach(line => line.remove());
|
||||
} catch (e) {
|
||||
console.debug('清除滚动中的十字线时出错:', e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ====== ChanMACD图表相关函数 ======
|
||||
|
||||
// 清除ChanMACD标注
|
||||
function clearChanMacdMarkers() {
|
||||
// 清除所有系列的标记
|
||||
if (tvWidget.series.chanMacdLineSeries) {
|
||||
tvWidget.series.chanMacdLineSeries.setMarkers([]);
|
||||
}
|
||||
|
||||
if (tvWidget.series.chanMacdSignalSeries) {
|
||||
tvWidget.series.chanMacdSignalSeries.setMarkers([]);
|
||||
}
|
||||
|
||||
if (tvWidget.series.chanMacdHistSeries) {
|
||||
tvWidget.series.chanMacdHistSeries.setMarkers([]);
|
||||
}
|
||||
// 清空全局UnitTF标记,避免旧数据残留影响主图合并
|
||||
window.unittfMarkers = [];
|
||||
}
|
||||
// 添加所有ChanMACD标记
|
||||
function addAllChanMacdMarkers(segList, unittfList, histsetList, stateMarkers) {
|
||||
const macdMarkers = [];
|
||||
const signalMarkers = [];
|
||||
const histMarkers = [];
|
||||
const boundaryMarkers = [];
|
||||
const uTooltipMarkers = [];
|
||||
|
||||
// 添加段标记到MACD线
|
||||
console.log('处理段标记,段数量:', segList.length);
|
||||
segList.forEach((seg, index) => {
|
||||
console.log(`段${index}:`, {
|
||||
start_time: seg.start_time,
|
||||
end_time: seg.end_time,
|
||||
seg_dir: seg.seg_dir,
|
||||
has_start: !!seg.start_time,
|
||||
has_end: !!seg.end_time
|
||||
});
|
||||
|
||||
if (!seg.start_time) {
|
||||
console.log(`段${index}没有开始时间,跳过`);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = new Date(seg.start_time).getTime() / 1000;
|
||||
console.log(`段${index}开始时间戳:`, startTime);
|
||||
macdMarkers.push({
|
||||
time: startTime,
|
||||
position: 'aboveBar',
|
||||
color: seg.seg_dir === 'ABOVE' ? '#e91e63' : '#4caf50',
|
||||
shape: 'square',
|
||||
text: `S${index}`,
|
||||
size: 0.5
|
||||
});
|
||||
|
||||
if (seg.end_time) {
|
||||
const endTime = new Date(seg.end_time).getTime() / 1000;
|
||||
console.log(`段${index}结束时间戳:`, endTime);
|
||||
macdMarkers.push({
|
||||
time: endTime,
|
||||
position: 'aboveBar',
|
||||
color: seg.seg_dir === 'ABOVE' ? '#e91e63' : '#4caf50',
|
||||
shape: 'square',
|
||||
text: `S${index}E`,
|
||||
size: 0.5
|
||||
});
|
||||
}
|
||||
});
|
||||
console.log('生成的段标记数量:', macdMarkers.length);
|
||||
|
||||
// 添加UnitTF标记(用于U)
|
||||
console.log('DEBUG: U 源数据条数:', Array.isArray(unittfList) ? unittfList.length : 'not array');
|
||||
unittfList.forEach((unittf, index) => {
|
||||
if (!unittf.start_time || unittf.invalid) return;
|
||||
|
||||
const startTime = new Date(unittf.start_time).getTime() / 1000;
|
||||
if (index === 0) {
|
||||
console.log('DEBUG: U0 示例:', unittf);
|
||||
}
|
||||
const startMarker = {
|
||||
time: startTime,
|
||||
position: unittf.dir > 0 ? 'aboveBar' : 'belowBar',
|
||||
color: unittf.dir > 0 ? '#ff9800' : '#9c27b0',
|
||||
shape: 'circle',
|
||||
text: `U${index}`,
|
||||
size: 0.5
|
||||
};
|
||||
signalMarkers.push(startMarker);
|
||||
// tooltip(开始)
|
||||
uTooltipMarkers.push({
|
||||
time: startTime,
|
||||
tooltip: `<div style="color: ${startMarker.color}; font-weight: bold;">
|
||||
UnitTF(${unittf.dir > 0 ? '正区' : '负区'}) 开始<br>
|
||||
峰值: ${unittf.peak_abs ?? '-'} 长度: ${unittf.length ?? '-'}<br>
|
||||
类型: ${unittf.start_type ?? '-'}<br>
|
||||
时间: ${unittf.start_time}
|
||||
</div>`
|
||||
});
|
||||
|
||||
if (unittf.end_time) {
|
||||
const endTime = new Date(unittf.end_time).getTime() / 1000;
|
||||
const endMarker = {
|
||||
time: endTime,
|
||||
position: unittf.dir > 0 ? 'aboveBar' : 'belowBar',
|
||||
color: unittf.dir > 0 ? '#ff9800' : '#9c27b0',
|
||||
shape: 'circle',
|
||||
text: `U${index}E`,
|
||||
size: 0.5
|
||||
};
|
||||
signalMarkers.push(endMarker);
|
||||
// tooltip(结束)
|
||||
uTooltipMarkers.push({
|
||||
time: endTime,
|
||||
tooltip: `<div style="color: ${endMarker.color}; font-weight: bold;">
|
||||
UnitTF(${unittf.dir > 0 ? '正区' : '负区'}) 结束<br>
|
||||
峰值: ${unittf.peak_abs ?? '-'} 长度: ${unittf.length ?? '-'}<br>
|
||||
类型: ${unittf.end_type ?? '-'}<br>
|
||||
时间: ${unittf.end_time}
|
||||
</div>`
|
||||
});
|
||||
}
|
||||
});
|
||||
// 识别"U 结束与新 U 开始同一根K"的边界,并显示合成标记(即使前一个U是 invalid 也显示边界)
|
||||
for (let i = 0; i + 1 < unittfList.length; i++) {
|
||||
const cur = unittfList[i];
|
||||
const nxt = unittfList[i + 1];
|
||||
if (!cur.end_time || !nxt.start_time) continue;
|
||||
const tEnd = new Date(cur.end_time).getTime();
|
||||
const tStart = new Date(nxt.start_time).getTime();
|
||||
if (!isNaN(tEnd) && tEnd === tStart) {
|
||||
const ts = Math.floor(tEnd / 1000);
|
||||
const color = nxt.dir > 0 ? '#ffb74d' : '#ba68c8';
|
||||
const marker = {
|
||||
time: ts,
|
||||
position: nxt.dir > 0 ? 'aboveBar' : 'belowBar',
|
||||
color: color,
|
||||
shape: 'square',
|
||||
text: 'U↔',
|
||||
size: 0.6
|
||||
};
|
||||
boundaryMarkers.push(marker);
|
||||
uTooltipMarkers.push({
|
||||
time: ts,
|
||||
tooltip: `<div style="color: ${color}; font-weight: bold;">\n U 结束 + 新 U 开始 (边界)<br>\n 结束方向: ${cur.dir > 0 ? '正区' : '负区'} → 新方向: ${nxt.dir > 0 ? '正区' : '负区'}<br>\n 时间: ${nxt.start_time}\n </div>`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 添加HistSet标记到Histogram
|
||||
histsetList.forEach((histset, index) => {
|
||||
if (!histset.start_time) return;
|
||||
|
||||
const startTime = new Date(histset.start_time).getTime() / 1000;
|
||||
if (false) {
|
||||
histMarkers.push({
|
||||
time: startTime,
|
||||
position: histset.histset_dir === 'ABOVE' ? 'aboveBar' : 'belowBar',
|
||||
color: histset.histset_dir === 'ABOVE' ? '#4caf50' : '#f44336',
|
||||
shape: 'arrowUp',
|
||||
text: `H${index}`,
|
||||
size: 0.5
|
||||
});
|
||||
|
||||
if (histset.end_time) {
|
||||
const endTime = new Date(histset.end_time).getTime() / 1000;
|
||||
histMarkers.push({
|
||||
time: endTime,
|
||||
position: histset.histset_dir === 'ABOVE' ? 'aboveBar' : 'belowBar',
|
||||
color: histset.histset_dir === 'ABOVE' ? '#4caf50' : '#f44336',
|
||||
shape: 'arrowDown',
|
||||
text: `H${index}E`,
|
||||
size: 0.5
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 设置所有标记
|
||||
console.log('设置段标记到图表,标记数量:', macdMarkers.length);
|
||||
if (tvWidget.series.chanMacdLineSeries && macdMarkers.length > 0) {
|
||||
tvWidget.series.chanMacdLineSeries.setMarkers(macdMarkers);
|
||||
console.log('✅ 段标记已设置到chanMacdLineSeries');
|
||||
} else {
|
||||
console.log('⚠️ 无法设置段标记:', {
|
||||
hasSeries: !!tvWidget.series.chanMacdLineSeries,
|
||||
markersLength: macdMarkers.length
|
||||
});
|
||||
}
|
||||
|
||||
// 保存到全局,供主图与分型一起统一合并绘制(仅在开关开启时)
|
||||
console.log('DEBUG: U 标记数量:', signalMarkers.length);
|
||||
const allowUMerge = (window.showUOnMain && window.showUOnElement);
|
||||
window.unittfMarkers = allowUMerge ? [...signalMarkers, ...boundaryMarkers] : [];
|
||||
if (uTooltipMarkers.length > 0) {
|
||||
if (window.fxMarkers) {
|
||||
window.fxMarkers = [ ...window.fxMarkers, ...uTooltipMarkers ];
|
||||
} else {
|
||||
window.fxMarkers = uTooltipMarkers;
|
||||
}
|
||||
}
|
||||
// 同时在ChanMACD的Signal子图上标注U
|
||||
if (tvWidget.series.chanMacdSignalSeries && (signalMarkers.length > 0 || boundaryMarkers.length > 0)) {
|
||||
tvWidget.series.chanMacdSignalSeries.setMarkers([...signalMarkers, ...boundaryMarkers]);
|
||||
}
|
||||
|
||||
if (tvWidget.series.chanMacdHistSeries && histMarkers.length > 0) {
|
||||
tvWidget.series.chanMacdHistSeries.setMarkers(histMarkers);
|
||||
}
|
||||
|
||||
// 添加状态标记
|
||||
if (stateMarkers) {
|
||||
addStateMarkers(stateMarkers);
|
||||
}
|
||||
}
|
||||
// 添加状态标记
|
||||
function addStateMarkers(stateMarkers) {
|
||||
const stateMarkersList = [];
|
||||
const stateTooltips = [];
|
||||
|
||||
// 调试信息
|
||||
console.log('DEBUG: 状态标记数据:', stateMarkers);
|
||||
console.log('DEBUG: 高位列表长度:', stateMarkers.high_position_list ? stateMarkers.high_position_list.length : 0);
|
||||
console.log('DEBUG: 低位列表长度:', stateMarkers.low_position_list ? stateMarkers.low_position_list.length : 0);
|
||||
console.log('DEBUG: 高位空列表长度:', stateMarkers.high_empty_list ? stateMarkers.high_empty_list.length : 0);
|
||||
console.log('DEBUG: 低位空列表长度:', stateMarkers.low_empty_list ? stateMarkers.low_empty_list.length : 0);
|
||||
|
||||
// HP/HPE:仅使用高位术语(正负两侧统一展示为HP/HPE)
|
||||
const hpHeTemp = [];
|
||||
let hpIdx = 0; // 高位峰值计数
|
||||
let heIdx = 0; // 高位空(HPE)计数
|
||||
(stateMarkers.high_position_list || []).forEach(m => {
|
||||
if (!m.time) return;
|
||||
const t = new Date(m.time).getTime()/1000;
|
||||
const color = '#e91e63';
|
||||
hpHeTemp.push({ t, position: 'belowBar', color, text: `HP${hpIdx}` });
|
||||
stateTooltips.push({
|
||||
time: t,
|
||||
tooltip: `<div style="color:${color};font-weight:bold;">HP${hpIdx} 峰值<br>MACD:${(m.macd??'').toFixed?.(4)||m.macd}<br>SIGNAL:${(m.signal??'').toFixed?.(4)||m.signal}<br>HIST:${(m.macdhist??'').toFixed?.(4)||m.macdhist}</div>`
|
||||
});
|
||||
hpIdx++;
|
||||
});
|
||||
(stateMarkers.low_position_list || []).forEach(m => {
|
||||
if (!m.time) return;
|
||||
const t = new Date(m.time).getTime()/1000;
|
||||
const color = '#4caf50';
|
||||
// 低位峰值也统一标记为 HP(按需求不使用 LP)
|
||||
hpHeTemp.push({ t, position: 'aboveBar', color, text: `HP${hpIdx}` });
|
||||
stateTooltips.push({
|
||||
time: t,
|
||||
tooltip: `<div style=\"color:${color};font-weight:bold;\">HP${hpIdx} 峰值(正区)<br>MACD:${(m.macd??'').toFixed?.(4)||m.macd}<br>SIGNAL:${(m.signal??'').toFixed?.(4)||m.signal}<br>HIST:${(m.macdhist??'').toFixed?.(4)||m.macdhist}</div>`
|
||||
});
|
||||
hpIdx++;
|
||||
});
|
||||
(stateMarkers.high_empty_list || []).forEach(m => {
|
||||
if (!m.time) return;
|
||||
const t = new Date(m.time).getTime()/1000;
|
||||
const color = '#ff9800';
|
||||
hpHeTemp.push({ t, position: 'belowBar', color, text: `HPE${heIdx}` });
|
||||
stateTooltips.push({
|
||||
time: t,
|
||||
tooltip: `<div style="color:${color};font-weight:bold;">HPE${heIdx} 黄白交叉<br>MACD:${(m.macd??'').toFixed?.(4)||m.macd}<br>SIGNAL:${(m.signal??'').toFixed?.(4)||m.signal}<br>HIST:${(m.macdhist??'').toFixed?.(4)||m.macdhist}</div>`
|
||||
});
|
||||
heIdx++;
|
||||
});
|
||||
(stateMarkers.low_empty_list || []).forEach(m => {
|
||||
if (!m.time) return;
|
||||
const t = new Date(m.time).getTime()/1000;
|
||||
const color = '#17a2b8';
|
||||
// 低位空也统一标记为 HPE(按需求不使用 LPE)
|
||||
hpHeTemp.push({ t, position: 'aboveBar', color, text: `HPE${heIdx}` });
|
||||
stateTooltips.push({
|
||||
time: t,
|
||||
tooltip: `<div style=\"color:${color};font-weight:bold;\">HPE${heIdx} 黄白交叉(正区)<br>MACD:${(m.macd??'').toFixed?.(4)||m.macd}<br>SIGNAL:${(m.signal??'').toFixed?.(4)||m.signal}<br>HIST:${(m.macdhist??'').toFixed?.(4)||m.macdhist}</div>`
|
||||
});
|
||||
heIdx++;
|
||||
});
|
||||
hpHeTemp.sort((a,b)=>a.t-b.t).forEach(it => {
|
||||
stateMarkersList.push({
|
||||
time: it.t,
|
||||
position: it.position,
|
||||
color: it.color,
|
||||
shape: 'diamond',
|
||||
text: it.text,
|
||||
size: 0.65
|
||||
});
|
||||
});
|
||||
|
||||
// 设置状态标记到 MACD 线
|
||||
if (tvWidget.series.chanMacdLineSeries && stateMarkersList.length > 0) {
|
||||
tvWidget.series.chanMacdLineSeries.setMarkers(stateMarkersList);
|
||||
console.log('✅ 状态标记已设置到MACD线,数量:', stateMarkersList.length);
|
||||
}
|
||||
|
||||
// 将状态标记的 tooltip 合并入全局,主图悬浮可见
|
||||
if (stateTooltips.length > 0) {
|
||||
if (window.fxMarkers) {
|
||||
window.fxMarkers = [...window.fxMarkers, ...stateTooltips];
|
||||
} else {
|
||||
window.fxMarkers = stateTooltips;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保留原函数用于向后兼容(但不使用)
|
||||
function addChanMacdSegMarkers(segList) {
|
||||
const markers = [];
|
||||
segList.forEach((seg, index) => {
|
||||
if (!seg.start_time) return;
|
||||
|
||||
const startTime = new Date(seg.start_time).getTime() / 1000;
|
||||
if (false){
|
||||
// 添加起点标记
|
||||
markers.push({
|
||||
time: startTime,
|
||||
position: 'aboveBar',
|
||||
color: seg.seg_dir === 'ABOVE' ? '#e91e63' : '#4caf50',
|
||||
shape: 'square',
|
||||
text: `S${index}`,
|
||||
size: 0.5
|
||||
});
|
||||
|
||||
// 如果有结束时间,添加结束标记
|
||||
if (seg.end_time) {
|
||||
const endTime = new Date(seg.end_time).getTime() / 1000;
|
||||
markers.push({
|
||||
time: endTime,
|
||||
position: 'aboveBar',
|
||||
color: seg.seg_dir === 'ABOVE' ? '#e91e63' : '#4caf50',
|
||||
shape: 'square',
|
||||
text: `S${index}E`,
|
||||
size: 0.5
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 设置标记到MACD线上
|
||||
if (tvWidget.series.chanMacdLineSeries && markers.length > 0) {
|
||||
tvWidget.series.chanMacdLineSeries.setMarkers(markers);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加UnitTF标注
|
||||
function addChanMacdUnitTFMarkers(unittfList) {
|
||||
const markers = [];
|
||||
unittfList.forEach((unittf, index) => {
|
||||
if (!unittf.start_time || unittf.invalid) return;
|
||||
|
||||
const startTime = new Date(unittf.start_time).getTime() / 1000;
|
||||
|
||||
// 添加起点标记
|
||||
markers.push({
|
||||
time: startTime,
|
||||
position: 'belowBar',
|
||||
color: unittf.dir > 0 ? '#ff9800' : '#9c27b0',
|
||||
shape: 'circle',
|
||||
text: `U${index}`,
|
||||
size: 0.5
|
||||
});
|
||||
|
||||
// 如果有结束时间,添加结束标记
|
||||
if (unittf.end_time) {
|
||||
const endTime = new Date(unittf.end_time).getTime() / 1000;
|
||||
markers.push({
|
||||
time: endTime,
|
||||
position: 'belowBar',
|
||||
color: unittf.dir > 0 ? '#ff9800' : '#9c27b0',
|
||||
shape: 'circle',
|
||||
text: `U${index}E`,
|
||||
size: 0.5
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 设置标记到信号线上
|
||||
if (tvWidget.series.chanMacdSignalSeries && markers.length > 0) {
|
||||
tvWidget.series.chanMacdSignalSeries.setMarkers(markers);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加HistSet标注
|
||||
function addChanMacdHistSetMarkers(histsetList) {
|
||||
const markers = [];
|
||||
histsetList.forEach((histset, index) => {
|
||||
if (!histset.start_time) return;
|
||||
|
||||
const startTime = new Date(histset.start_time).getTime() / 1000;
|
||||
|
||||
// 添加起点标记
|
||||
markers.push({
|
||||
time: startTime,
|
||||
position: histset.histset_dir === 'ABOVE' ? 'aboveBar' : 'belowBar',
|
||||
color: histset.histset_dir === 'ABOVE' ? '#4caf50' : '#f44336',
|
||||
shape: 'arrowUp',
|
||||
text: `H${index}`,
|
||||
size: 0.5
|
||||
});
|
||||
|
||||
// 如果有结束时间,添加结束标记
|
||||
if (histset.end_time) {
|
||||
const endTime = new Date(histset.end_time).getTime() / 1000;
|
||||
markers.push({
|
||||
time: endTime,
|
||||
position: histset.histset_dir === 'ABOVE' ? 'aboveBar' : 'belowBar',
|
||||
color: histset.histset_dir === 'ABOVE' ? '#4caf50' : '#f44336',
|
||||
shape: 'arrowDown',
|
||||
text: `H${index}E`,
|
||||
size: 0.5
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 设置标记到柱状图上
|
||||
if (tvWidget.series.chanMacdHistSeries && markers.length > 0) {
|
||||
tvWidget.series.chanMacdHistSeries.setMarkers(markers);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
/* state.js */
|
||||
|
||||
var currentData = null;
|
||||
var lastRequestId = 0; // 防止过期响应覆盖新数据
|
||||
var FEATURES = {
|
||||
trendFilter: false, // 趋势筛选/趋势小图等
|
||||
dataReplay: false, // 数据回放功能
|
||||
legacyMacd: false // 旧MACD(已弃用)
|
||||
};
|
||||
var tables = {};
|
||||
// ======= 趋势筛选(币对) =======
|
||||
var trendTable = null;
|
||||
|
||||
var trendDetailTable = null;
|
||||
var trendChart = null;
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
/* trend.js */
|
||||
function initTrendTables() {
|
||||
if (!FEATURES.trendFilter) return; // 未启用则跳过初始化
|
||||
if (!trendTable) {
|
||||
trendTable = $('#trendFilterTable').DataTable({
|
||||
paging: true,
|
||||
searching: false,
|
||||
info: true,
|
||||
order: [[4, 'desc']],
|
||||
});
|
||||
}
|
||||
if (!trendDetailTable) {
|
||||
trendDetailTable = $('#trendDetailTable').DataTable({
|
||||
paging: true,
|
||||
searching: false,
|
||||
info: true,
|
||||
order: [[0, 'desc']],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bindTrendControls() {
|
||||
// 双向绑定强度滑块与数字框
|
||||
$('#trendMinStrength').on('input change', function(){
|
||||
$('#trendMinStrengthNum').val($(this).val());
|
||||
});
|
||||
$('#trendMinStrengthNum').on('input change', function(){
|
||||
let v = Math.max(0, Math.min(100, parseFloat($(this).val()||0)));
|
||||
$(this).val(v);
|
||||
$('#trendMinStrength').val(v);
|
||||
});
|
||||
|
||||
// 周期变化时,自动填充当前时间回溯300根K线的时间范围
|
||||
$('#trendTimeframe').on('change', function(){
|
||||
const tf = $(this).val();
|
||||
const step = window.timeframeToMs(tf) || (60*60*1000);
|
||||
const now = new Date();
|
||||
const endMs = now.getTime();
|
||||
const startMs = endMs - 300 * step;
|
||||
const toLocal = (ms) => new Date(ms - new Date(ms).getTimezoneOffset()*60000).toISOString().slice(0,16);
|
||||
$('#trendEnd').val(toLocal(endMs));
|
||||
$('#trendStart').val(toLocal(startMs));
|
||||
});
|
||||
|
||||
$('#btnTrendFilter').on('click', async function(){
|
||||
await runTrendFilter();
|
||||
});
|
||||
}
|
||||
|
||||
async function runTrendFilter() {
|
||||
if (!FEATURES.trendFilter) return; // 未启用则早退
|
||||
initTrendTables();
|
||||
trendTable.clear().draw();
|
||||
|
||||
const timeframe = $('#trendTimeframe').val();
|
||||
const direction = $('#trendDirection').val();
|
||||
const stage = $('#trendStage').val();
|
||||
const minStrength = $('#trendMinStrength').val();
|
||||
const symbols = $('#trendSymbols').val();
|
||||
let start = $('#trendStart').val();
|
||||
let end = $('#trendEnd').val();
|
||||
|
||||
// 前端必须提供时间范围:若为空,自动以当前时间回溯300根
|
||||
if (!start || !end) {
|
||||
const step = window.timeframeToMs(timeframe) || (60*60*1000);
|
||||
const now = Date.now();
|
||||
const startMsAuto = now - 300 * step;
|
||||
const toLocal = (ms) => new Date(ms - new Date(ms).getTimezoneOffset()*60000).toISOString().slice(0,16);
|
||||
if (!end) $('#trendEnd').val(toLocal(now));
|
||||
if (!start) $('#trendStart').val(toLocal(startMsAuto));
|
||||
start = $('#trendStart').val();
|
||||
end = $('#trendEnd').val();
|
||||
}
|
||||
|
||||
let startMs = start ? new Date(start).getTime() : '';
|
||||
let endMs = end ? new Date(end).getTime() : '';
|
||||
|
||||
const params = $.param({
|
||||
timeframe: timeframe,
|
||||
direction: direction || '',
|
||||
stage: stage || '',
|
||||
min_strength: minStrength,
|
||||
symbols: symbols || '',
|
||||
start_time: startMs || '',
|
||||
end_time: endMs || ''
|
||||
});
|
||||
|
||||
// 显示筛选状态
|
||||
$('#trendFilterStatus').show();
|
||||
try {
|
||||
const res = await $.getJSON(`/api/trend_filter?${params}`);
|
||||
// 初筛后端结果,再次用前端方向筛选(避免后端噪声)
|
||||
const dirVal = $('#trendDirection').val();
|
||||
const rows = (res.results || [])
|
||||
.filter(r => {
|
||||
if (!dirVal) return true;
|
||||
return r.direction === dirVal;
|
||||
})
|
||||
.map(r => [
|
||||
r.symbol,
|
||||
new Date(r.time).toLocaleString('zh-CN', { timeZone: $('#timezone').val() || 'Asia/Shanghai' }),
|
||||
r.direction === 'bull' ? '多头' : (r.direction === 'bear' ? '空头' : '盘整'),
|
||||
r.stage === 'early' ? '初期' : (r.stage === 'mid' ? '中期' : '末期'),
|
||||
r.strength,
|
||||
r.close,
|
||||
r.ema5,
|
||||
r.ema10,
|
||||
r.ema26,
|
||||
r.ema52,
|
||||
`<button class="btn btn-sm btn-outline-primary" data-symbol="${r.symbol}" data-timeframe="${timeframe}">查看</button>`
|
||||
]);
|
||||
trendTable.rows.add(rows).draw();
|
||||
|
||||
// 绑定查看按钮
|
||||
$('#trendFilterTable').off('click', 'button').on('click', 'button', function(){
|
||||
const sym = $(this).data('symbol');
|
||||
const tf = $(this).data('timeframe');
|
||||
loadTrendDetail(sym, tf, startMs, endMs);
|
||||
});
|
||||
|
||||
// 精细化阶段判定(前端基于明细重算)
|
||||
refineTrendStages(Array.from(new Set((res.results||[]).map(r => r.symbol))).slice(0, 20), timeframe, startMs, endMs);
|
||||
} catch (e) {
|
||||
alert('趋势筛选失败: ' + e);
|
||||
} finally {
|
||||
$('#trendFilterStatus').hide();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrendDetail(symbol, timeframe, startMs, endMs) {
|
||||
const params = $.param({
|
||||
symbol: symbol,
|
||||
timeframe: timeframe,
|
||||
start_time: startMs || '',
|
||||
end_time: endMs || '',
|
||||
timezone: $('#timezone').val() || 'Asia/Shanghai'
|
||||
});
|
||||
|
||||
// 显示详情加载状态
|
||||
$('#trendDetailStatus').show();
|
||||
try {
|
||||
const data = await $.getJSON(`/api/trend_detail?${params}`);
|
||||
// 填表
|
||||
trendDetailTable.clear();
|
||||
(data.kline_data || []).forEach(row => {
|
||||
trendDetailTable.row.add([
|
||||
new Date(row.timestamp).toLocaleString('zh-CN', { timeZone: data.timezone }),
|
||||
row.open, row.high, row.low, row.close, row.volume,
|
||||
row.ema5, row.ema10, row.ema26, row.ema52
|
||||
]);
|
||||
});
|
||||
trendDetailTable.draw();
|
||||
|
||||
// 画图
|
||||
drawTrendChart(data);
|
||||
} catch (e) {
|
||||
alert('加载趋势详情失败: ' + e);
|
||||
} finally {
|
||||
$('#trendDetailStatus').hide();
|
||||
}
|
||||
}
|
||||
|
||||
function drawTrendChart(data) {
|
||||
const container = document.getElementById('trendChartContainer');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
const chart = LightweightCharts.createChart(container, {
|
||||
layout: { background: { color: '#ffffff' }, textColor: '#333' },
|
||||
rightPriceScale: { visible: true },
|
||||
timeScale: { timeVisible: true, secondsVisible: false },
|
||||
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||||
grid: { vertLines: { color: '#eee' }, horzLines: { color: '#eee' } },
|
||||
autoSize: true
|
||||
});
|
||||
trendChart = chart;
|
||||
|
||||
const candle = chart.addCandlestickSeries();
|
||||
// 关闭均线的价格线与最后值标签,仅保留K线的当前价格虚线
|
||||
const ema5 = chart.addLineSeries({ color: '#ff0000', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
|
||||
const ema10 = chart.addLineSeries({ color: '#2962FF', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
|
||||
const ema26 = chart.addLineSeries({ color: '#008000', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
|
||||
const ema52 = chart.addLineSeries({ color: '#800080', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
|
||||
|
||||
const k = (data.kline_data || []).map(r => ({
|
||||
time: Math.floor(r.timestamp / 1000),
|
||||
open: Number(r.open), high: Number(r.high), low: Number(r.low), close: Number(r.close)
|
||||
}));
|
||||
candle.setData(k);
|
||||
|
||||
// 前端过滤均线前导缺失/无效值,避免绘制为0
|
||||
const sanitizeMA = (field) => {
|
||||
const rows = data.kline_data || [];
|
||||
const out = [];
|
||||
let started = false;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
const raw = r[field];
|
||||
const v = Number(raw);
|
||||
const valid = Number.isFinite(v) && v > 0;
|
||||
if (!started) {
|
||||
if (!valid) continue;
|
||||
started = true;
|
||||
}
|
||||
if (!valid) continue;
|
||||
out.push({ time: Math.floor(r.timestamp / 1000), value: v });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
ema5.setData(sanitizeMA('ema5'));
|
||||
ema10.setData(sanitizeMA('ema10'));
|
||||
ema26.setData(sanitizeMA('ema26'));
|
||||
ema52.setData(sanitizeMA('ema52'));
|
||||
|
||||
// 趋势线(使用返回的拟合参数)
|
||||
const trend = data.trend_line || null;
|
||||
if (trend && k.length > 1) {
|
||||
const L = Math.min(trend.length, k.length);
|
||||
const startIdx = k.length - L;
|
||||
const lineData = [];
|
||||
for (let i = 0; i < L; i++) {
|
||||
const y = trend.slope * i + trend.intercept;
|
||||
const point = { time: k[startIdx + i].time, value: y };
|
||||
lineData.push(point);
|
||||
}
|
||||
const trendSeries = chart.addLineSeries({ color: '#ffa500', lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false });
|
||||
trendSeries.setData(lineData);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 前端精细化阶段判定 =====
|
||||
function computeEMA() {
|
||||
if (window.App && window.App.Indicators && typeof window.App.Indicators.computeEMA === 'function') {
|
||||
return window.App.Indicators.computeEMA.apply(null, arguments);
|
||||
}
|
||||
console.warn('computeEMA 未就绪,返回空数组');
|
||||
return [];
|
||||
}
|
||||
|
||||
function computeMACDSeries(close) {
|
||||
const ema12 = computeEMA(close, 12);
|
||||
const ema26 = computeEMA(close, 26);
|
||||
const macd = close.map((_, i) => (ema12[i] != null && ema26[i] != null) ? (ema12[i] - ema26[i]) : null);
|
||||
const signal = computeEMA(macd.map(v => v ?? null), 9);
|
||||
const hist = macd.map((v, i) => (v != null && signal[i] != null) ? (v - signal[i]) : null);
|
||||
return { macd, signal, hist };
|
||||
}
|
||||
|
||||
function slope(series, win) {
|
||||
const n = series.length;
|
||||
const k = Math.min(win, n);
|
||||
if (k < 3) return 0;
|
||||
const y = series.slice(n - k).filter(v => v != null && isFinite(v));
|
||||
if (y.length < 3) return 0;
|
||||
const x = [...Array(y.length).keys()];
|
||||
const xm = x.reduce((a,b)=>a+b,0)/x.length;
|
||||
const ym = y.reduce((a,b)=>a+b,0)/y.length;
|
||||
let num = 0, den = 0;
|
||||
for (let i=0;i<x.length;i++){ num += (x[i]-xm)*(y[i]-ym); den += (x[i]-xm)*(x[i]-xm); }
|
||||
return den ? num/den : 0;
|
||||
}
|
||||
|
||||
function classifyStageFrontend(kline, directionHint) {
|
||||
const close = kline.map(r => Number(r.close));
|
||||
const ema26 = computeEMA(close, 26);
|
||||
const ema52 = computeEMA(close, 52);
|
||||
const last = close[close.length-1];
|
||||
const e26 = ema26[ema26.length-1];
|
||||
const e52 = ema52[ema52.length-1];
|
||||
const s26 = slope(ema26, 20);
|
||||
const s52 = slope(ema52, 30);
|
||||
const dist52 = (e52 && isFinite(e52)) ? (last - e52)/e52 : 0;
|
||||
const { hist } = computeMACDSeries(close);
|
||||
const recent = hist.slice(-9).filter(v => v != null);
|
||||
const earlier = hist.slice(-18, -9).filter(v => v != null);
|
||||
const growth = (recent.length && earlier.length) ? (avgAbs(recent) - avgAbs(earlier)) : 0;
|
||||
|
||||
function avgAbs(arr){ return arr.reduce((a,b)=>a+Math.abs(b),0)/arr.length; }
|
||||
|
||||
let direction = directionHint;
|
||||
if (!direction) {
|
||||
if (e26 > e52 && s26 > 0 && s52 > 0) direction = 'bull';
|
||||
else if (e26 < e52 && s26 < 0 && s52 < 0) direction = 'bear';
|
||||
else direction = 'sideways';
|
||||
}
|
||||
|
||||
let stage = 'early';
|
||||
const ad = Math.abs(dist52);
|
||||
if (direction === 'bull') {
|
||||
if (ad < 0.03 && growth > 0) stage = 'early';
|
||||
else if (ad < 0.10 && (growth >= 0 || s26 > 0)) stage = 'mid';
|
||||
else stage = 'late';
|
||||
} else if (direction === 'bear') {
|
||||
if (ad < 0.03 && growth > 0) stage = 'early';
|
||||
else if (ad < 0.10 && (growth >= 0 || s26 < 0)) stage = 'mid';
|
||||
else stage = 'late';
|
||||
} else {
|
||||
stage = 'early';
|
||||
}
|
||||
return { direction, stage };
|
||||
}
|
||||
|
||||
async function refineTrendStages(symbols, timeframe, startMs, endMs) {
|
||||
if (!symbols || symbols.length === 0) return;
|
||||
// 在表头上方提示
|
||||
const info = $('<div class="text-muted mb-2" id="refineInfo">正在优化阶段判定...</div>');
|
||||
$('#trendFilterTable').before(info);
|
||||
const tz = $('#timezone').val() || 'Asia/Shanghai';
|
||||
const selectedDir = $('#trendDirection').val(); // bull/bear/sideways/''
|
||||
for (const sym of symbols) {
|
||||
try {
|
||||
const params = $.param({ symbol: sym, timeframe, start_time: startMs, end_time: endMs, timezone: tz });
|
||||
const data = await $.getJSON(`/api/trend_detail?${params}`);
|
||||
const { direction, stage } = classifyStageFrontend(data.kline_data || [], null);
|
||||
// 若与选择的方向不一致,则在前端移除该行,避免"选择多头仍出现空头/盘整"
|
||||
if (selectedDir && direction !== selectedDir) {
|
||||
if (trendTable) {
|
||||
trendTable.rows().every(function(){
|
||||
const rowData = this.data();
|
||||
if (rowData && rowData[0] === sym) {
|
||||
this.remove();
|
||||
}
|
||||
});
|
||||
trendTable.draw(false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 否则更新该行方向与阶段展示
|
||||
$('#trendFilterTable tbody tr').each(function(){
|
||||
const tds = $(this).find('td');
|
||||
if (tds.eq(0).text() === sym) {
|
||||
tds.eq(2).text(direction === 'bull' ? '多头' : (direction === 'bear' ? '空头' : '盘整'));
|
||||
tds.eq(3).text(stage === 'early' ? '初期' : stage === 'mid' ? '中期' : '末期');
|
||||
}
|
||||
});
|
||||
} catch(e) {
|
||||
// 忽略单个失败
|
||||
}
|
||||
}
|
||||
info.remove();
|
||||
}
|
||||
|
||||
// 页面初始化时绑定控件
|
||||
$(function(){
|
||||
initTrendTables();
|
||||
bindTrendControls();
|
||||
});
|
||||
var tvWidget = {
|
||||
mainChart: null,
|
||||
volumeChart: null,
|
||||
macdChart: null,
|
||||
chanMacdChart: null, // 新增ChanMACD图表
|
||||
series: {
|
||||
candleSeries: null,
|
||||
barSeries: null,
|
||||
lineSeries: null,
|
||||
areaSeries: null,
|
||||
baselineSeries: null,
|
||||
renkoSeries: null,
|
||||
volumeSeries: null,
|
||||
atrLineSeries: null,
|
||||
macdLineSeries: null,
|
||||
signalLineSeries: null,
|
||||
histogramSeries: null,
|
||||
mainBiSeries: [],
|
||||
mainSegSeries: [],
|
||||
mainZsSeries: [],
|
||||
mainUncompletedZsSeries: [],
|
||||
elementBiSeries: [],
|
||||
elementSegSeries: [],
|
||||
elementZsSeries: [],
|
||||
elementUncompletedZsSeries: [],
|
||||
subSubBiSeries: [],
|
||||
subSubSegSeries: [],
|
||||
subSubZsSeries: [],
|
||||
subSubUncompletedZsSeries: [],
|
||||
mainBollingerSeries: [],
|
||||
elementBollingerSeries: [],
|
||||
maSeries: [], // 添加均线系列
|
||||
bbSeries: [], // 添加布林带系列
|
||||
ema52Series: [], // 添加EMA52系列数组
|
||||
chanMacdLineSeries: null, // ChanMACD线
|
||||
chanMacdSignalSeries: null, // ChanMACD信号线
|
||||
chanMacdHistSeries: null, // ChanMACD柱状图
|
||||
chanMacdSegSeries: [], // ChanMACD段
|
||||
chanMacdUnitTFSeries: [], // ChanMACD UnitTF
|
||||
chanMacdHistSetSeries: [] // ChanMACD HistSet
|
||||
},
|
||||
state: {
|
||||
isInitialized: false,
|
||||
visibleRange: null,
|
||||
logicalRange: null
|
||||
}
|
||||
};
|
||||
|
||||
// 默认EMA初始化哨兵,防止删除后再次被自动添加
|
||||
var hasInitializedDefaultMAs = false;
|
||||
|
||||
// 买卖点类型定义
|
||||
const TRADE_POINT_TYPE = {
|
||||
BUY1: 1, // 一类买点
|
||||
BUY2: 2, // 二类买点
|
||||
BUY3: 3, // 三类买点
|
||||
SELL1: -1, // 一类卖点
|
||||
SELL2: -2, // 二类卖点
|
||||
SELL3: -3 // 三类卖点
|
||||
};
|
||||
|
||||
// 买卖点样式定义
|
||||
const TRADE_POINT_STYLE = {
|
||||
[TRADE_POINT_TYPE.BUY1]: {color: '#FF1744', shape: 'arrowUp', text: '买1', size: 2},
|
||||
[TRADE_POINT_TYPE.BUY2]: {color: '#F50057', shape: 'circle', text: '买2', size: 2},
|
||||
[TRADE_POINT_TYPE.BUY3]: {color: '#D500F9', shape: 'square', text: '买3', size: 2},
|
||||
[TRADE_POINT_TYPE.SELL1]: {color: '#00E676', shape: 'arrowDown', text: '卖1', size: 2},
|
||||
[TRADE_POINT_TYPE.SELL2]: {color: '#00B0FF', shape: 'circle', text: '卖2', size: 2},
|
||||
[TRADE_POINT_TYPE.SELL3]: {color: '#FFEA00', shape: 'square', text: '卖3', size: 2}
|
||||
};
|
||||
|
||||
// 定义标记垂直偏移系数 - 合约市场通常波动较大,减小偏移防止显示在范围外
|
||||
const TRADE_POINT_OFFSET = {
|
||||
[TRADE_POINT_TYPE.BUY1]: 0, // 一类买点向下偏移2.0%的价格
|
||||
[TRADE_POINT_TYPE.BUY2]: 0, // 二类买点向下偏移1.5%的价格
|
||||
[TRADE_POINT_TYPE.BUY3]: 0, // 三类买点向下偏移1.0%的价格
|
||||
[TRADE_POINT_TYPE.SELL1]: 0, // 一类卖点向上偏移2.0%的价格
|
||||
[TRADE_POINT_TYPE.SELL2]: 0,// 二类卖点向上偏移1.5%的价格
|
||||
[TRADE_POINT_TYPE.SELL3]: 0 // 三类卖点向上偏移1.0%的价格
|
||||
};
|
||||
|
||||
// 更改为基于价格百分比的垂直偏移 - 合约市场适用的更小偏移
|
||||
const PRICE_PERCENT_OFFSET = {
|
||||
[TRADE_POINT_TYPE.BUY1]: 0, // 一类买点向下偏移价格的0.2%
|
||||
[TRADE_POINT_TYPE.BUY2]: 0, // 二类买点向下偏移价格的0.15%
|
||||
[TRADE_POINT_TYPE.BUY3]: 0, // 三类买点向下偏移价格的0.1%
|
||||
[TRADE_POINT_TYPE.SELL1]: 0, // 一类卖点向上偏移价格的0.2%
|
||||
[TRADE_POINT_TYPE.SELL2]: 0, // 二类卖点向上偏移价格的0.15%
|
||||
[TRADE_POINT_TYPE.SELL3]: 0 // 三类卖点向上偏移价格的0.1%
|
||||
};
|
||||
|
||||
// 对于高价格标的如BTC,设置零偏移,完全不影响价格显示
|
||||
const USE_FIXED_OFFSET = true; // 是否使用固定偏移而非百分比
|
||||
const PRICE_FIXED_OFFSET = {
|
||||
[TRADE_POINT_TYPE.BUY1]: 0, // 一类买点零偏移
|
||||
[TRADE_POINT_TYPE.BUY2]: 0, // 二类买点零偏移
|
||||
[TRADE_POINT_TYPE.BUY3]: 0, // 三类买点零偏移
|
||||
[TRADE_POINT_TYPE.SELL1]: 0, // 一类卖点零偏移
|
||||
[TRADE_POINT_TYPE.SELL2]: 0, // 二类卖点零偏移
|
||||
[TRADE_POINT_TYPE.SELL3]: 0 // 三类卖点零偏移
|
||||
};
|
||||
|
||||
// 同一时间点的标记堆叠间距系数
|
||||
const STACK_OFFSET_FACTOR = 5; // 增加堆叠标记的间距
|
||||
|
||||
// 添加CSS样式定义买卖点标记的样式
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = `
|
||||
.point-tooltip {
|
||||
position: absolute;
|
||||
background: rgba(40, 40, 40, 0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
max-width: 300px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.buy-point {
|
||||
color: #ff1744;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sell-point {
|
||||
color: #00e676;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.buy-marker {
|
||||
background-color: #ff1744;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.sell-marker {
|
||||
background-color: #00e676;
|
||||
border: 2px solid white;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
// 添加买卖点悬浮提示元素
|
||||
const tooltipElement = document.createElement('div');
|
||||
tooltipElement.className = 'point-tooltip';
|
||||
// document.body.appendChild(tooltipElement);
|
||||
|
||||
// 添加自定义十字线信息显示
|
||||
const crosshairTooltip = document.createElement('div');
|
||||
crosshairTooltip.className = 'crosshair-tooltip';
|
||||
crosshairTooltip.style.position = 'absolute';
|
||||
crosshairTooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
||||
crosshairTooltip.style.color = 'white';
|
||||
crosshairTooltip.style.padding = '5px 10px';
|
||||
crosshairTooltip.style.borderRadius = '4px';
|
||||
crosshairTooltip.style.fontSize = '12px';
|
||||
crosshairTooltip.style.zIndex = '1000';
|
||||
crosshairTooltip.style.pointerEvents = 'none';
|
||||
crosshairTooltip.style.display = 'none';
|
||||
// document.body.appendChild(crosshairTooltip);
|
||||
|
||||
// 助手函数:转换UTC时间到所选时区
|
||||
function convertToTimezone(utcDate, timezone) {
|
||||
return new Date(utcDate).toLocaleString('zh-CN', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取UTC时间戳(秒)
|
||||
function getTimestamp(dateStr) {
|
||||
return new Date(dateStr).getTime() / 1000;
|
||||
}
|
||||
|
||||
// 获取时区偏移量(小时)
|
||||
function getTimezoneOffset(timezone) {
|
||||
// 手动定义已知时区的偏移量
|
||||
const offsets = {
|
||||
'UTC': 0,
|
||||
'Asia/Shanghai': 8,
|
||||
'America/New_York': -4, // 夏令时可能是-4,冬令时是-5
|
||||
'Europe/London': 0, // 夏令时可能是+1,冬令时是0
|
||||
'Europe/Berlin': 1, // 夏令时可能是+2,冬令时是+1
|
||||
'Asia/Tokyo': 9
|
||||
};
|
||||
|
||||
return offsets[timezone] || 0;
|
||||
}
|
||||
|
||||
// 从日期字符串获取时间戳,应用时区偏移
|
||||
function getAdjustedTimestamp(dateStr, applyOffset = true) {
|
||||
const date = new Date(dateStr);
|
||||
const timestamp = Math.floor(date.getTime() / 1000);
|
||||
|
||||
if (!applyOffset) {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
// 不再手动调整时区偏移,使用JavaScript的内置时区支持
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
// 添加时区选择器变更事件
|
||||
$('#timezone').change(function() {
|
||||
if (currentData) {
|
||||
// 重新渲染图表和数据表以使用新的时区
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
updateTables(currentData);
|
||||
}
|
||||
});
|
||||
|
||||
// 添加MACD复选框变更事件
|
||||
$('#showMacd').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,826 @@
|
||||
/* ui.js */
|
||||
function loadSymbols() {
|
||||
$.get('/api/symbols', function(data) {
|
||||
if (Array.isArray(data)) {
|
||||
const $select = $('#symbol');
|
||||
const currentSymbol = $select.val(); // 保存当前选中的值
|
||||
$select.empty();
|
||||
|
||||
data.forEach(function(symbol) {
|
||||
$select.append($('<option>', {
|
||||
value: symbol,
|
||||
text: symbol
|
||||
}));
|
||||
});
|
||||
|
||||
// 如果有保存的选中值,恢复它
|
||||
if (currentSymbol && data.includes(currentSymbol)) {
|
||||
$select.val(currentSymbol);
|
||||
} else {
|
||||
// 设置默认值为BTC/USDT:USDT
|
||||
$select.val('BTC/USDT:USDT');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 设置默认时间范围
|
||||
function setDefaultTimeRange() {
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
||||
|
||||
// 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm
|
||||
$('#end_time').val(formatDatetimeLocal(now));
|
||||
$('#start_time').val(formatDatetimeLocal(oneDayAgo));
|
||||
}
|
||||
// 格式化日期为datetime-local输入框格式
|
||||
function formatDatetimeLocal(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
// 页面加载时初始化
|
||||
$(document).ready(function() {
|
||||
// 从本地存储中恢复时区设置
|
||||
const savedTimezone = localStorage.getItem('selectedTimezone');
|
||||
if (savedTimezone) {
|
||||
$('#timezone').val(savedTimezone);
|
||||
console.log('从本地存储恢复时区设置:', savedTimezone);
|
||||
}
|
||||
|
||||
// 初始化数据源切换:先按数据源重新拉取周期元信息,再切换 UI
|
||||
$('#dataSource').on('change', function() {
|
||||
const dataSource = $(this).val();
|
||||
const apiSrc = dataSource === 'a_stock' ? 'a_stock' : 'crypto';
|
||||
$.getJSON('/api/chart_metadata', { source: apiSrc })
|
||||
.done(function(meta) {
|
||||
applyChartMetadata(meta);
|
||||
})
|
||||
.always(function() {
|
||||
if (dataSource === 'crypto') {
|
||||
$('#cryptoSymbolContainer').show();
|
||||
$('#astockSymbolContainer').hide();
|
||||
if (window.astockStatusInterval) {
|
||||
clearInterval(window.astockStatusInterval);
|
||||
window.astockStatusInterval = null;
|
||||
}
|
||||
loadSymbols();
|
||||
} else if (dataSource === 'a_stock') {
|
||||
$('#cryptoSymbolContainer').hide();
|
||||
$('#astockSymbolContainer').show();
|
||||
loadAStockSymbols();
|
||||
startAStockStatusUpdater();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 检查初始数据源设置
|
||||
const initialDataSource = $('#dataSource').val();
|
||||
if (initialDataSource === 'a_stock') {
|
||||
$.getJSON('/api/chart_metadata', { source: 'a_stock' })
|
||||
.done(function(meta) {
|
||||
applyChartMetadata(meta);
|
||||
})
|
||||
.always(function() {
|
||||
loadAStockSymbols();
|
||||
startAStockStatusUpdater();
|
||||
setTimeout(function() {
|
||||
updateChart();
|
||||
}, 300);
|
||||
});
|
||||
} else {
|
||||
setTimeout(function() {
|
||||
updateChart();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// 初始化交易对下拉菜单
|
||||
$('#symbol').val('BTC/USDT:USDT');
|
||||
$('#astockSymbol').val('000001');
|
||||
if (initialDataSource !== 'a_stock') {
|
||||
const mainDefault = window.DEFAULT_MAIN_TIMEFRAME || $('#timeframe option:first').val();
|
||||
const elementDefault = window.DEFAULT_ELEMENT_TIMEFRAME || $('#elementTimeframe option:first').val();
|
||||
if (mainDefault) {
|
||||
$('#timeframe').val(mainDefault);
|
||||
}
|
||||
if (elementDefault) {
|
||||
$('#elementTimeframe').val(elementDefault);
|
||||
}
|
||||
}
|
||||
|
||||
// 测试打印时区偏移量
|
||||
console.log('当前时区偏移量 (UTC+8):', getTimezoneOffset('Asia/Shanghai'));
|
||||
console.log('当前时区偏移量 (UTC):', getTimezoneOffset('UTC'));
|
||||
|
||||
const now = new Date();
|
||||
console.log('当前时间UTC:', now.toUTCString());
|
||||
console.log('当前时间本地:', now.toString());
|
||||
console.log('当前时间戳(秒):', now.getTime()/1000);
|
||||
console.log('UTC时间戳:', Math.floor(now.getTime()/1000));
|
||||
|
||||
// 设置默认时间范围
|
||||
setDefaultTimeRange();
|
||||
|
||||
// 默认禁用买卖点显示
|
||||
$('#showTradePoints').prop('checked', false);
|
||||
|
||||
// 尝试加载更多交易对
|
||||
loadSymbols();
|
||||
|
||||
// 初始化图表:默认加密货币延迟拉取;若首屏为 A 股则在 chart_metadata 完成后再 updateChart
|
||||
if (initialDataSource !== 'a_stock') {
|
||||
setTimeout(function() {
|
||||
updateChart();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// 初始化自动刷新功能
|
||||
initAutoRefresh();
|
||||
|
||||
// 确保在文档加载完成后初始化时区设置
|
||||
// 默认设置为Shanghai时区
|
||||
if (!$('#timezone').val()) {
|
||||
$('#timezone').val('Asia/Shanghai');
|
||||
}
|
||||
|
||||
// 记录当前时区设置
|
||||
console.log('页面加载完成,当前时区设置:', $('#timezone').val());
|
||||
|
||||
// 添加自定义事件处理 - 让时区选择变更立即生效
|
||||
$('#timezone').on('change', function() {
|
||||
const newTimezone = $(this).val();
|
||||
console.log('时区已更改为:', newTimezone);
|
||||
|
||||
// 保存到本地存储,下次访问时自动使用
|
||||
localStorage.setItem('selectedTimezone', newTimezone);
|
||||
|
||||
// 如果已有数据,重新渲染图表和表格
|
||||
if (currentData) {
|
||||
// 先销毁现有图表实例
|
||||
if (tvWidget.mainChart) {
|
||||
try {
|
||||
// 清理EMA52系列
|
||||
clearEMA52Series();
|
||||
// 销毁主图表及其关联的线系列
|
||||
tvWidget.mainChart = null;
|
||||
tvWidget.volumeChart = null;
|
||||
tvWidget.atrChart = null;
|
||||
tvWidget.macdChart = null;
|
||||
// 重置系列数据
|
||||
tvWidget.series = {
|
||||
candleSeries: null,
|
||||
lineSeries: null,
|
||||
barSeries: null,
|
||||
areaSeries: null,
|
||||
baselineSeries: null,
|
||||
renkoSeries: null,
|
||||
volumeSeries: null,
|
||||
atrLineSeries: null,
|
||||
macdLineSeries: null,
|
||||
signalLineSeries: null,
|
||||
histogramSeries: null,
|
||||
mainBiSeries: [],
|
||||
mainSegSeries: [],
|
||||
mainZsSeries: [],
|
||||
mainUncompletedZsSeries: [],
|
||||
elementBiSeries: [],
|
||||
elementSegSeries: [],
|
||||
elementZsSeries: [],
|
||||
elementUncompletedZsSeries: [],
|
||||
tradePointSeries: [],
|
||||
mainBollingerSeries: [],
|
||||
elementBollingerSeries: [],
|
||||
maSeries: [], // 添加均线系列
|
||||
bbSeries: [], // 添加布林带系列
|
||||
ema52Series: [] // 添加EMA52系列数组
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('销毁图表错误:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用新的时区重新初始化图表
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
|
||||
// 重新渲染图表数据
|
||||
renderChart();
|
||||
|
||||
// 更新表格
|
||||
updateTables(currentData);
|
||||
}
|
||||
});
|
||||
|
||||
// 页面加载完成后初始化
|
||||
$(document).ready(function() {
|
||||
// 设置默认的筛选时间(最近7天)
|
||||
const now = new Date();
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
|
||||
|
||||
// 添加页面滚动事件监听器,清除十字线延长线
|
||||
$(window).on('scroll', function() {
|
||||
try {
|
||||
// 清除所有十字线延长线,防止它们跟着页面滚动
|
||||
const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line');
|
||||
existingVolumeLines.forEach(line => line.remove());
|
||||
const existingAtrLines = document.querySelectorAll('.atr-crosshair-line');
|
||||
existingAtrLines.forEach(line => line.remove());
|
||||
const existingMacdLines = document.querySelectorAll('.macd-crosshair-line');
|
||||
existingMacdLines.forEach(line => line.remove());
|
||||
const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line');
|
||||
existingChanMacdLines.forEach(line => line.remove());
|
||||
} catch (e) {
|
||||
console.debug('清除滚动中的十字线时出错:', e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
});
|
||||
// 自动刷新相关变量
|
||||
let autoRefreshTimer = null;
|
||||
let nextRefreshTime = null;
|
||||
// 初始化自动刷新功能
|
||||
function initAutoRefresh() {
|
||||
// 监听自动刷新勾选框变化
|
||||
$('#autoRefresh').change(function() {
|
||||
if ($(this).is(':checked')) {
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
stopAutoRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听刷新频率变化
|
||||
$('#refreshInterval').change(function() {
|
||||
if ($('#autoRefresh').is(':checked')) {
|
||||
// 如果自动刷新已开启,重启定时器
|
||||
stopAutoRefresh();
|
||||
startAutoRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
// 开始自动刷新
|
||||
function startAutoRefresh() {
|
||||
// 停止已有的刷新定时器
|
||||
stopAutoRefresh();
|
||||
|
||||
// 获取刷新频率(分钟)
|
||||
const interval = parseFloat($('#refreshInterval').val()) || 5;
|
||||
const intervalMs = interval * 60 * 1000;
|
||||
|
||||
console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒)`);
|
||||
|
||||
// 计算下次刷新时间
|
||||
nextRefreshTime = new Date(Date.now() + intervalMs);
|
||||
updateNextRefreshTimeDisplay();
|
||||
|
||||
// 启动定时器
|
||||
autoRefreshTimer = setInterval(function() {
|
||||
// 更新结束时间为当前时间
|
||||
updateEndTimeToNow();
|
||||
|
||||
// 刷新图表
|
||||
updateChart();
|
||||
|
||||
// 更新下次刷新时间
|
||||
nextRefreshTime = new Date(Date.now() + intervalMs);
|
||||
updateNextRefreshTimeDisplay();
|
||||
}, intervalMs);
|
||||
|
||||
// 启动倒计时显示
|
||||
startCountdownDisplay();
|
||||
|
||||
// 显示下次刷新时间
|
||||
$('#nextRefreshTime').show();
|
||||
}
|
||||
|
||||
// 更新结束时间为当前时间
|
||||
function updateEndTimeToNow() {
|
||||
const now = new Date();
|
||||
$('#end_time').val(formatDatetimeLocal(now));
|
||||
console.log('已更新结束时间为当前时间:', formatDatetimeLocal(now));
|
||||
}
|
||||
|
||||
// 停止自动刷新
|
||||
function stopAutoRefresh() {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer);
|
||||
autoRefreshTimer = null;
|
||||
}
|
||||
|
||||
// 停止倒计时显示
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
|
||||
// 隐藏下次刷新时间
|
||||
$('#nextRefreshTime').hide();
|
||||
}
|
||||
|
||||
// 更新下次刷新时间显示
|
||||
function updateNextRefreshTimeDisplay() {
|
||||
if (!nextRefreshTime) return;
|
||||
|
||||
const timeStr = nextRefreshTime.toLocaleTimeString();
|
||||
$('#nextRefreshTime').text(`下次刷新: ${timeStr}`);
|
||||
}
|
||||
// 倒计时定时器
|
||||
let countdownTimer = null;
|
||||
|
||||
// 启动倒计时显示
|
||||
function startCountdownDisplay() {
|
||||
// 清除已有的倒计时
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
}
|
||||
|
||||
// 启动新的倒计时,每秒更新一次
|
||||
countdownTimer = setInterval(function() {
|
||||
if (!nextRefreshTime) return;
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = nextRefreshTime - now;
|
||||
|
||||
if (diffMs <= 0) {
|
||||
// 已经到达或超过刷新时间,等待刷新发生
|
||||
$('#nextRefreshTime').text('正在刷新...');
|
||||
} else {
|
||||
// 计算剩余时间
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
|
||||
// 如果时间超过1分钟,显示分和秒
|
||||
if (diffSec >= 60) {
|
||||
const minutes = Math.floor(diffSec / 60);
|
||||
const seconds = diffSec % 60;
|
||||
// 格式化显示
|
||||
const timeStr = `${minutes}分${seconds.toString().padStart(2, '0')}秒后刷新`;
|
||||
$('#nextRefreshTime').text(timeStr);
|
||||
} else {
|
||||
// 少于1分钟只显示秒数
|
||||
const timeStr = `${diffSec}秒后刷新`;
|
||||
$('#nextRefreshTime').text(timeStr);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 将时间周期映射到数值(保留此函数以供后端API调用)
|
||||
function mapTimeframeToInterval(timeframe) {
|
||||
const mapping = {
|
||||
'1m': '1',
|
||||
'3m': '3',
|
||||
'5m': '5',
|
||||
'15m': '15',
|
||||
'30m': '30',
|
||||
'1h': '60',
|
||||
'2h': '120',
|
||||
'4h': '240',
|
||||
'6h': '360',
|
||||
'8h': '480',
|
||||
'12h': '720',
|
||||
'1d': 'D',
|
||||
'3d': '3D',
|
||||
'1w': 'W',
|
||||
'1M': 'M'
|
||||
};
|
||||
return mapping[timeframe] || '5';
|
||||
}
|
||||
|
||||
// 只重绘分形元素(笔、线段、中枢),保留现有的K线、MACD和成交量
|
||||
function redrawFractalElements() {
|
||||
if (!tvWidget || !tvWidget.mainChart) return;
|
||||
|
||||
const mainChart = tvWidget.mainChart;
|
||||
const logicalRange = mainChart.timeScale().getVisibleLogicalRange();
|
||||
const visibleRange = mainChart.timeScale().getVisibleRange();
|
||||
|
||||
// 确保使用主周期的K线和MACD数据
|
||||
if (currentData.original_kline_data) {
|
||||
currentData.kline_data = currentData.original_kline_data;
|
||||
}
|
||||
if (currentData.original_macd) {
|
||||
currentData.macd = currentData.original_macd;
|
||||
}
|
||||
// 清除冗余引用,帮助GC回收
|
||||
delete currentData.original_kline_data;
|
||||
delete currentData.original_macd;
|
||||
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
|
||||
setTimeout(() => {
|
||||
if (tvWidget && tvWidget.mainChart) {
|
||||
if (logicalRange) {
|
||||
tvWidget.mainChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
} else if (visibleRange) {
|
||||
tvWidget.mainChart.timeScale().setVisibleRange(visibleRange);
|
||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(visibleRange);
|
||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(visibleRange);
|
||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(visibleRange);
|
||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(visibleRange);
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
// 只更新分形元素(笔、线段、中枢)的表格数据
|
||||
function updateFractalTables() {
|
||||
if (!currentData) return;
|
||||
|
||||
const data = currentData;
|
||||
|
||||
// 笔数据表更新
|
||||
if (tables.bi) {
|
||||
tables.bi.clear().destroy();
|
||||
}
|
||||
|
||||
// 使用小周期笔数据(如果存在)
|
||||
const biData = data.element_bi_list || data.bi_list;
|
||||
const biSource = data.element_bi_list ? '元素周期' : '主周期';
|
||||
console.log(`表格显示${biSource}笔数据,共${biData ? biData.length : 0}条`);
|
||||
|
||||
tables.bi = $('#biTable').DataTable({
|
||||
data: biData || [],
|
||||
order: [[0, 'desc']],
|
||||
pageLength: 25,
|
||||
columns: [
|
||||
{ data: 'start_time', render: formatTime },
|
||||
{ data: 'end_time', render: formatTime },
|
||||
{ data: 'sure_time', render: formatConfirmTime },
|
||||
{ data: 'start_price', render: formatPrice },
|
||||
{ data: 'end_price', render: formatPrice },
|
||||
{ data: 'direction', render: formatDirection },
|
||||
{ data: 'macd_div', render: formatMacdValue }
|
||||
]
|
||||
});
|
||||
|
||||
// 线段数据表更新
|
||||
if (tables.seg) {
|
||||
tables.seg.clear().destroy();
|
||||
}
|
||||
|
||||
// 使用小周期线段数据(如果存在)
|
||||
const segData = data.element_seg_list || data.seg_list;
|
||||
const segSource = data.element_seg_list ? '元素周期' : '主周期';
|
||||
console.log(`表格显示${segSource}线段数据,共${segData ? segData.length : 0}条`);
|
||||
|
||||
tables.seg = $('#segTable').DataTable({
|
||||
data: segData || [],
|
||||
order: [[0, 'desc']],
|
||||
pageLength: 25,
|
||||
columns: [
|
||||
{ data: 'start_time', render: formatTime },
|
||||
{ data: 'end_time', render: formatTime },
|
||||
{ data: 'sure_time', render: formatConfirmTime },
|
||||
{ data: 'start_price', render: formatPrice },
|
||||
{ data: 'end_price', render: formatPrice },
|
||||
{ data: 'direction', render: formatDirection }
|
||||
]
|
||||
});
|
||||
|
||||
// 中枢数据表更新
|
||||
if (tables.zs) {
|
||||
tables.zs.clear().destroy();
|
||||
}
|
||||
|
||||
// 使用小周期中枢数据(如果存在)
|
||||
const zsData = data.element_zs_list || data.zs_list;
|
||||
const zsSource = data.element_zs_list ? '元素周期' : '主周期';
|
||||
console.log(`表格显示${zsSource}中枢数据,共${zsData ? zsData.length : 0}条`);
|
||||
|
||||
tables.zs = $('#zsTable').DataTable({
|
||||
data: zsData || [],
|
||||
order: [[0, 'desc']],
|
||||
pageLength: 25,
|
||||
columns: [
|
||||
{ data: 'start_time', render: formatTime },
|
||||
{ data: 'end_time', render: formatTime },
|
||||
{ data: 'zg', render: formatPrice },
|
||||
{ data: 'zd', render: formatPrice }
|
||||
]
|
||||
});
|
||||
|
||||
// 更新数据源信息
|
||||
setupDataSourceInfo(data);
|
||||
}
|
||||
|
||||
// 刷新图表并更新表格
|
||||
function refreshChart(data) {
|
||||
// 检查是否接收到数据
|
||||
if (!data) {
|
||||
console.error('未收到数据,无法刷新图表');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.element_timeframe) {
|
||||
$('#elementTimeframe').val(data.element_timeframe);
|
||||
}
|
||||
|
||||
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
|
||||
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
|
||||
if (tvWidget && tvWidget.mainChart) {
|
||||
try {
|
||||
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||
console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView));
|
||||
} catch (e) {
|
||||
console.warn('保存图表视图失败:', e);
|
||||
window._pendingRestoreView = null;
|
||||
}
|
||||
}
|
||||
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
|
||||
// 更新表格数据
|
||||
updateTables(data);
|
||||
|
||||
if (currentData && currentData.ema52_dict) {
|
||||
updateEMA52Display(currentData);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function refreshChartOnly() {
|
||||
// 仅使用当前数据刷新图表显示,不从服务器加载新数据
|
||||
if (currentData) {
|
||||
console.log('仅刷新图表显示,不重新获取数据');
|
||||
refreshChart(currentData);
|
||||
} else {
|
||||
console.log('没有当前数据,无法刷新显示');
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定主周期MACD背离显示开关
|
||||
$('#showMainMacdDiv').change(function() {
|
||||
refreshChartOnly();
|
||||
});
|
||||
|
||||
// 绑定次周期MACD背离显示开关
|
||||
$('#showElementMacdDiv').change(function() {
|
||||
refreshChartOnly();
|
||||
});
|
||||
|
||||
// 绑定分型类型显示开关
|
||||
$('#showKlcFxType').change(function() {
|
||||
refreshChartOnly();
|
||||
});
|
||||
|
||||
// 绑定小周期分型显示开关
|
||||
$('#showElementKlcFxType').change(function() {
|
||||
refreshChart(currentData);
|
||||
});
|
||||
|
||||
|
||||
// 绑定布林带显示变更事件
|
||||
$('#showMainBollinger').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
$('#showElementBollinger').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 绑定K线周期切换
|
||||
$('input[name="klinePeriod"]').change(function() {
|
||||
refreshChart(currentData);
|
||||
});
|
||||
|
||||
// 绑定主图U显示开关
|
||||
$('#toggleUOnMain').change(function() {
|
||||
window.showUOnMain = $('#toggleUOnMain').is(':checked');
|
||||
refreshChartOnly();
|
||||
});
|
||||
|
||||
// 次周期 U 显示开关
|
||||
$('#toggleUOnElement').change(function() {
|
||||
window.showUOnElement = $('#toggleUOnElement').is(':checked');
|
||||
refreshChartOnly();
|
||||
});
|
||||
|
||||
// 买卖点显示开关
|
||||
$('#showMainBsp').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
$('#showElementBsp').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 在控制台输出当前显示状态
|
||||
console.log('当前显示状态:', {
|
||||
'showOriginalKline': $('#showOriginalKline').is(':checked'),
|
||||
'showMainBi': $('#showMainBi').is(':checked'),
|
||||
'showMainSeg': $('#showMainSeg').is(':checked'),
|
||||
'showMainZs': $('#showMainZs').is(':checked'),
|
||||
'showVolume': false,
|
||||
'showMacd': $('#showMacd').is(':checked'),
|
||||
'showKlcFxType': $('#showKlcFxType').is(':checked'),
|
||||
'showKluFxType': $('#showKluFxType').is(':checked'),
|
||||
'showElementKlcFxType': $('#showElementKlcFxType').is(':checked'),
|
||||
'showElementKluFxType': $('#showElementKluFxType').is(':checked'),
|
||||
'showTradePoints': $('#showTradePoints').is(':checked'),
|
||||
'showMainBollinger': $('#showMainBollinger').is(':checked'),
|
||||
'showElementBollinger': $('#showElementBollinger').is(':checked'),
|
||||
'timeframe': $('#timeframe').val(),
|
||||
'elementTimeframe': $('#elementTimeframe').val(),
|
||||
'timezone': $('#timezone').val(),
|
||||
'start_time': $('#start_time').val(),
|
||||
'end_time': $('#end_time').val()
|
||||
});
|
||||
|
||||
// 初始化提示工具
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
})
|
||||
|
||||
|
||||
// 获取A股股票列表(全市场,来自 /api/a_stocks)
|
||||
function loadAStockSymbols() {
|
||||
const $select = $('#astockSymbol');
|
||||
const currentSymbol = $select.val();
|
||||
$select.prop('disabled', true);
|
||||
$.get('/api/a_stocks', function(data) {
|
||||
$select.prop('disabled', false);
|
||||
if (!Array.isArray(data)) {
|
||||
console.error('加载A股股票列表失败: 返回非数组', data);
|
||||
return;
|
||||
}
|
||||
$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(xhr) {
|
||||
$select.prop('disabled', false);
|
||||
console.error('加载A股股票列表失败', xhr && xhr.status);
|
||||
});
|
||||
}
|
||||
// 检测交易对类型并返回相应的配置
|
||||
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股交易时间状态更新器已启动');
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 均线系统全局变量
|
||||
var movingAverages = []; // 存储所有均线配置
|
||||
var maIdCounter = 0; // 均线ID计数器
|
||||
|
||||
// 布林带系统全局变量
|
||||
var bollingerBands = []; // 存储所有布林带配置
|
||||
var bbIdCounter = 0; // 布林带ID计数器
|
||||
|
||||
// 清理EMA52系列
|
||||
+5
-1373
File diff suppressed because it is too large
Load Diff
@@ -1,496 +1,5 @@
|
||||
/**
|
||||
* 缠论自定义指标 — TradingView Advanced Chart
|
||||
*
|
||||
* 从 chanIndicator.ts 转换为 vanilla JS。
|
||||
* 在 K 线上叠加:笔/段(实线+虚线)、中枢(填色区域)、买卖点(文字标签)。
|
||||
*
|
||||
* 依赖:
|
||||
* window.chanLookupHolder — 当前 Chan 结构数据
|
||||
* window.commitChanLookup — 累积合并新数据
|
||||
* window.makeChanIndicator — 创建 TV study 定义
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict'
|
||||
|
||||
// ---- BSP 子类型枚举 ----
|
||||
var BSP_SUBTYPES = ['T1', 'T1P', 'T2', 'T2S', 'T3A', 'T3B']
|
||||
|
||||
// ---- 全局状态: chanLookupHolder ----
|
||||
window.chanLookupHolder = {
|
||||
current: null,
|
||||
key: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* 累积/替换 chanLookup
|
||||
* 同 key 累积合并(历史区间的 BSP 标签持续保留)
|
||||
* 不同 key 整个替换
|
||||
*/
|
||||
window.commitChanLookup = function (fresh, key) {
|
||||
var holder = window.chanLookupHolder
|
||||
if (holder.key !== key || !holder.current) {
|
||||
holder.current = fresh
|
||||
holder.key = key
|
||||
return
|
||||
}
|
||||
// 同 key 合并
|
||||
var target = holder.current.byTimeMs
|
||||
fresh.byTimeMs.forEach(function (e, t) {
|
||||
var existed = target.get(t)
|
||||
if (existed) {
|
||||
Object.assign(existed, e)
|
||||
} else {
|
||||
target.set(t, e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 工具函数 ----
|
||||
|
||||
function lowerBound(arr, v) {
|
||||
var lo = 0, hi = arr.length
|
||||
while (lo < hi) {
|
||||
var mid = (lo + hi) >> 1
|
||||
if (arr[mid] < v) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
function upperBound(arr, v) {
|
||||
var lo = 0, hi = arr.length
|
||||
while (lo < hi) {
|
||||
var mid = (lo + hi) >> 1
|
||||
if (arr[mid] <= v) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 ChanLookup:将 Chan 结构数据映射到每个 bar 的指标值
|
||||
*
|
||||
* @param {Object} slice - ChanSlice {bis, segs, zs, segzs, bsps, seg_bsps}
|
||||
* @param {Array} bars - OHLCV bars [{t: ms, h, l}, ...]
|
||||
* @returns {Object} {byTimeMs: Map<ms, BarEntry>}
|
||||
*/
|
||||
window.buildChanLookup = function (slice, bars) {
|
||||
var byTimeMs = new Map()
|
||||
|
||||
function ensure(tsMs) {
|
||||
// tsMs 已是毫秒(来自 data_provider 的 timestamp),无需再转换
|
||||
var key = tsMs
|
||||
var e = byTimeMs.get(key)
|
||||
if (!e) {
|
||||
e = {}
|
||||
byTimeMs.set(key, e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
var sortedBarTimes = bars.map(function (b) { return b.t }).sort(function (a, b) { return a - b })
|
||||
|
||||
// 线性插值填充笔/段到每个 bar
|
||||
function fillLine(t0, t1, p0, p1, field) {
|
||||
var lo = lowerBound(sortedBarTimes, t0)
|
||||
var hi = upperBound(sortedBarTimes, t1)
|
||||
var span = hi - 1 - lo
|
||||
if (span <= 0) {
|
||||
if (lo < sortedBarTimes.length) ensure(sortedBarTimes[lo])[field] = p0
|
||||
return
|
||||
}
|
||||
var step = (p1 - p0) / span
|
||||
for (var i = lo; i < hi; i++) {
|
||||
ensure(sortedBarTimes[i])[field] = p0 + step * (i - lo)
|
||||
}
|
||||
}
|
||||
|
||||
// 笔
|
||||
if (slice.bis) {
|
||||
slice.bis.forEach(function (b) {
|
||||
fillLine(b.t0, b.t1, b.p0, b.p1, b.sure ? 'bi' : 'bi_pending')
|
||||
})
|
||||
}
|
||||
|
||||
// 段
|
||||
if (slice.segs) {
|
||||
slice.segs.forEach(function (s) {
|
||||
fillLine(s.t0, s.t1, s.p0, s.p1, s.sure ? 'seg' : 'seg_pending')
|
||||
})
|
||||
}
|
||||
|
||||
// 中枢填充:区间内每根 bar 写入 top/bottom
|
||||
function fillZs(t0, t1, high, low, topField, botField) {
|
||||
var lo = lowerBound(sortedBarTimes, t0)
|
||||
var hi = upperBound(sortedBarTimes, t1)
|
||||
for (var i = lo; i < hi; i++) {
|
||||
var e = ensure(sortedBarTimes[i])
|
||||
e[topField] = high
|
||||
e[botField] = low
|
||||
}
|
||||
}
|
||||
|
||||
if (slice.zs) {
|
||||
slice.zs.forEach(function (z) {
|
||||
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom')
|
||||
})
|
||||
}
|
||||
if (slice.segzs) {
|
||||
slice.segzs.forEach(function (z) {
|
||||
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom')
|
||||
})
|
||||
}
|
||||
|
||||
// BSP 买卖点标记
|
||||
function placeBsps(list, prefix) {
|
||||
if (!list) return
|
||||
list.forEach(function (bsp) {
|
||||
var dir = bsp.is_buy ? 'buy' : 'sell'
|
||||
var e = ensure(bsp.t)
|
||||
var types = bsp.types || []
|
||||
types.forEach(function (raw) {
|
||||
var t = String(raw).toUpperCase()
|
||||
if (BSP_SUBTYPES.indexOf(t) === -1) return
|
||||
var key = prefix + '_' + dir + '_' + t
|
||||
e[key] = 1
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
placeBsps(slice.bsps, 'bi_bsp')
|
||||
placeBsps(slice.seg_bsps, 'seg_bsp')
|
||||
|
||||
return { byTimeMs: byTimeMs }
|
||||
}
|
||||
|
||||
// ---- 样式持久化 ----
|
||||
|
||||
function currentTheme() {
|
||||
try { return localStorage.getItem('chart-theme') || 'light' }
|
||||
catch (e) { return 'light' }
|
||||
}
|
||||
|
||||
function chanStyleKey() {
|
||||
return 'chan-indicator-styles-v7-' + currentTheme()
|
||||
}
|
||||
|
||||
function loadSavedChanStyles() {
|
||||
try {
|
||||
var raw = localStorage.getItem(chanStyleKey())
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
window.saveChanStyles = function (sv) {
|
||||
try {
|
||||
localStorage.setItem(chanStyleKey(), JSON.stringify({
|
||||
styles: sv && sv.styles ? sv.styles : {},
|
||||
filledAreasStyle: sv && sv.filledAreasStyle ? sv.filledAreasStyle : {},
|
||||
}))
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---- 主体:创建 TV 自定义指标定义 ----
|
||||
|
||||
window.makeChanIndicator = function () {
|
||||
var saved = loadSavedChanStyles()
|
||||
var isDark = currentTheme() === 'dark'
|
||||
var biColor = isDark ? '#ffffff' : '#000000'
|
||||
var segColor = isDark ? '#42a5f5' : '#1565c0'
|
||||
|
||||
function mergeStyle(id, base) {
|
||||
var savedStyle = (saved && saved.styles && saved.styles[id]) || {}
|
||||
var merged = {}
|
||||
var keys = Object.keys(base).concat(Object.keys(savedStyle))
|
||||
keys.forEach(function (k) {
|
||||
if (k in savedStyle) merged[k] = savedStyle[k]
|
||||
else merged[k] = base[k]
|
||||
})
|
||||
return merged
|
||||
}
|
||||
|
||||
function mergeFill(id, base) {
|
||||
var savedFill = (saved && saved.filledAreasStyle && saved.filledAreasStyle[id]) || {}
|
||||
var merged = {}
|
||||
var keys = Object.keys(base).concat(Object.keys(savedFill))
|
||||
keys.forEach(function (k) {
|
||||
if (k in savedFill) merged[k] = savedFill[k]
|
||||
else merged[k] = base[k]
|
||||
})
|
||||
return merged
|
||||
}
|
||||
|
||||
// 构建 plots 数组
|
||||
var plots = [
|
||||
{ id: 'bi', type: 'line' },
|
||||
{ id: 'bi_pending', type: 'line' },
|
||||
{ id: 'seg', type: 'line' },
|
||||
{ id: 'seg_pending', type: 'line' },
|
||||
{ id: 'zs_top', type: 'line' },
|
||||
{ id: 'zs_bottom', type: 'line' },
|
||||
{ id: 'segzs_top', type: 'line' },
|
||||
{ id: 'segzs_bottom', type: 'line' },
|
||||
]
|
||||
|
||||
BSP_SUBTYPES.forEach(function (t) {
|
||||
plots.push({ id: 'bi_bsp_buy_' + t, type: 'chars' })
|
||||
plots.push({ id: 'bi_bsp_sell_' + t, type: 'chars' })
|
||||
plots.push({ id: 'seg_bsp_buy_' + t, type: 'chars' })
|
||||
plots.push({ id: 'seg_bsp_sell_' + t, type: 'chars' })
|
||||
})
|
||||
|
||||
// 构建 styles 对象
|
||||
// bi_pending/seg_pending: 虚线(linestyle:2),加粗 + 高亮色,确保末完成笔/段清晰可见
|
||||
var pendingBiColor = isDark ? '#ff9800' : '#e65100' // orange
|
||||
var pendingSegColor = isDark ? '#e040fb' : '#aa00ff' // purple
|
||||
var styles = {
|
||||
bi: mergeStyle('bi', {
|
||||
linestyle: 0, linewidth: 1, plottype: 0, trackPrice: false,
|
||||
transparency: 0, visible: true, color: biColor, display: 3,
|
||||
}),
|
||||
bi_pending: mergeStyle('bi_pending', {
|
||||
linestyle: 2, linewidth: 2, plottype: 0, trackPrice: false,
|
||||
transparency: 0, visible: true, color: pendingBiColor, display: 3,
|
||||
}),
|
||||
seg: mergeStyle('seg', {
|
||||
linestyle: 0, linewidth: 3, plottype: 0, trackPrice: false,
|
||||
transparency: 0, visible: true, color: segColor, display: 3,
|
||||
}),
|
||||
seg_pending: mergeStyle('seg_pending', {
|
||||
linestyle: 2, linewidth: 4, plottype: 0, trackPrice: false,
|
||||
transparency: 0, visible: true, color: pendingSegColor, display: 3,
|
||||
}),
|
||||
zs_top: mergeStyle('zs_top', {
|
||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||
transparency: 100, visible: false, color: '#e4eaf1', display: 0,
|
||||
}),
|
||||
zs_bottom: mergeStyle('zs_bottom', {
|
||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||
transparency: 100, visible: false, color: '#1565c0', display: 0,
|
||||
}),
|
||||
segzs_top: mergeStyle('segzs_top', {
|
||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||
transparency: 100, visible: false, color: '#ef6c00', display: 0,
|
||||
}),
|
||||
segzs_bottom: mergeStyle('segzs_bottom', {
|
||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||
transparency: 100, visible: false, color: '#ef6c00', display: 0,
|
||||
}),
|
||||
}
|
||||
|
||||
// BSP 样式
|
||||
BSP_SUBTYPES.forEach(function (t) {
|
||||
styles['bi_bsp_buy_' + t] = mergeStyle('bi_bsp_buy_' + t, {
|
||||
char: '●', location: 'BelowBar', visible: true, size: 'large',
|
||||
color: '#d32f2f', display: 3,
|
||||
})
|
||||
styles['bi_bsp_sell_' + t] = mergeStyle('bi_bsp_sell_' + t, {
|
||||
char: '●', location: 'AboveBar', visible: true, size: 'large',
|
||||
color: '#2e7d32', display: 3,
|
||||
})
|
||||
styles['seg_bsp_buy_' + t] = mergeStyle('seg_bsp_buy_' + t, {
|
||||
char: '●', location: 'BelowBar', visible: true, size: 'large',
|
||||
color: '#d32f2f', display: 3,
|
||||
})
|
||||
styles['seg_bsp_sell_' + t] = mergeStyle('seg_bsp_sell_' + t, {
|
||||
char: '●', location: 'AboveBar', visible: true, size: 'large',
|
||||
color: '#2e7d32', display: 3,
|
||||
})
|
||||
})
|
||||
|
||||
// 构建 style titles
|
||||
var styleTitles = {
|
||||
bi: { title: '笔', histogramBase: 0 },
|
||||
bi_pending: { title: '笔(虚)', histogramBase: 0 },
|
||||
seg: { title: '段', histogramBase: 0 },
|
||||
seg_pending: { title: '段(虚)', histogramBase: 0 },
|
||||
zs_top: { title: '中枢上沿', histogramBase: 0, isHidden: true },
|
||||
zs_bottom: { title: '中枢下沿', histogramBase: 0, isHidden: true },
|
||||
segzs_top: { title: '段中枢上沿', histogramBase: 0, isHidden: true },
|
||||
segzs_bottom: { title: '段中枢下沿', histogramBase: 0, isHidden: true },
|
||||
}
|
||||
|
||||
BSP_SUBTYPES.forEach(function (t) {
|
||||
// 类型名映射:T1/T2/T3A 是买点, T1P/T2S/T3B 是卖点
|
||||
var typeInfo = {
|
||||
T1: { cls: '一', side: 'buy', num: '1' },
|
||||
T1P: { cls: '一', side: 'sell', num: '1' },
|
||||
T2: { cls: '二', side: 'buy', num: '2' },
|
||||
T2S: { cls: '二', side: 'sell', num: '2' },
|
||||
T3A: { cls: '三', side: 'buy', num: '3' },
|
||||
T3B: { cls: '三', side: 'sell', num: '3' },
|
||||
}[t] || { cls: '', side: '', num: '' }
|
||||
var buyText = 'B' + typeInfo.num
|
||||
var sellText = 'S' + typeInfo.num
|
||||
var isBuyType = typeInfo.side === 'buy'
|
||||
var isSellType = typeInfo.side === 'sell'
|
||||
|
||||
// 笔中枢 BSP:全部可见
|
||||
styleTitles['bi_bsp_buy_' + t] = {
|
||||
title: '笔·' + typeInfo.cls + '类买点',
|
||||
isHidden: !isBuyType,
|
||||
text: buyText,
|
||||
}
|
||||
styleTitles['bi_bsp_sell_' + t] = {
|
||||
title: '笔·' + typeInfo.cls + '类卖点',
|
||||
isHidden: !isSellType,
|
||||
text: sellText,
|
||||
}
|
||||
// 段中枢 BSP:只有一类买卖点有实际数据
|
||||
var segBuyVisible = t === 'T1'
|
||||
var segSellVisible = t === 'T1P'
|
||||
styleTitles['seg_bsp_buy_' + t] = {
|
||||
title: '段·一类买点',
|
||||
isHidden: !segBuyVisible,
|
||||
text: '段B1',
|
||||
}
|
||||
styleTitles['seg_bsp_sell_' + t] = {
|
||||
title: '段·一类卖点',
|
||||
isHidden: !segSellVisible,
|
||||
text: '段S1',
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
name: '缠论',
|
||||
metainfo: {
|
||||
_metainfoVersion: 53,
|
||||
id: 'Chan@tv-basicstudies-5',
|
||||
scriptIdPart: '',
|
||||
description: 'Chan 缠论',
|
||||
shortDescription: '缠论',
|
||||
is_hidden_study: false,
|
||||
isCustomIndicator: true,
|
||||
is_price_study: true,
|
||||
linkedToSeries: true,
|
||||
format: { type: 'inherit' },
|
||||
plots: plots,
|
||||
filledAreas: [
|
||||
{ id: 'zs_fill', objAId: 'zs_top', objBId: 'zs_bottom', type: 'plot_plot',
|
||||
title: '中枢', isHidden: false },
|
||||
{ id: 'segzs_fill', objAId: 'segzs_top', objBId: 'segzs_bottom', type: 'plot_plot',
|
||||
title: '段中枢', isHidden: false },
|
||||
],
|
||||
defaults: {
|
||||
styles: styles,
|
||||
filledAreasStyle: {
|
||||
zs_fill: mergeFill('zs_fill', { color: '#f1d96a', visible: true, transparency: 75 }),
|
||||
segzs_fill: mergeFill('segzs_fill', { color: '#6361f7', visible: true, transparency: 75 }),
|
||||
},
|
||||
precision: 2,
|
||||
inputs: { epoch: 0 },
|
||||
},
|
||||
styles: styleTitles,
|
||||
inputs: [
|
||||
{ id: 'epoch', name: 'epoch', type: 'integer', defval: 0, isHidden: true },
|
||||
],
|
||||
},
|
||||
constructor: function () {
|
||||
var self = this
|
||||
this.init = function (ctx) {
|
||||
self._context = ctx
|
||||
}
|
||||
this.main = function (context) {
|
||||
// 32 个 plot: 8 结构 + 24 BSP
|
||||
var NANS = new Array(32).fill(NaN)
|
||||
// v31: sniffing pass 时 context.symbol.time 为 NaN
|
||||
var t = context.symbol.time
|
||||
if (isNaN(t)) return NANS
|
||||
|
||||
var lookup = window.chanLookupHolder.current
|
||||
if (!lookup) return NANS
|
||||
|
||||
var e = lookup.byTimeMs.get(t)
|
||||
if (!e) return NANS
|
||||
|
||||
var out = [
|
||||
e.bi != null ? e.bi : NaN,
|
||||
e.bi_pending != null ? e.bi_pending : NaN,
|
||||
e.seg != null ? e.seg : NaN,
|
||||
e.seg_pending != null ? e.seg_pending : NaN,
|
||||
e.zs_top != null ? e.zs_top : NaN,
|
||||
e.zs_bottom != null ? e.zs_bottom : NaN,
|
||||
e.segzs_top != null ? e.segzs_top : NaN,
|
||||
e.segzs_bottom != null ? e.segzs_bottom : NaN,
|
||||
]
|
||||
|
||||
BSP_SUBTYPES.forEach(function (sub) {
|
||||
out.push(
|
||||
e['bi_bsp_buy_' + sub] != null ? e['bi_bsp_buy_' + sub] : NaN,
|
||||
e['bi_bsp_sell_' + sub] != null ? e['bi_bsp_sell_' + sub] : NaN,
|
||||
e['seg_bsp_buy_' + sub] != null ? e['seg_bsp_buy_' + sub] : NaN,
|
||||
e['seg_bsp_sell_' + sub] != null ? e['seg_bsp_sell_' + sub] : NaN
|
||||
)
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Epoch bump 机制 ----
|
||||
var chanEpoch = 0
|
||||
var CHAN_STUDY_DESC = 'Chan 缠论'
|
||||
|
||||
/**
|
||||
* 确保缠论 study 存在并通过 epoch bump 触发重绘。
|
||||
* 与 TradingViewChart.tsx 中 ensureAndPokeChanStudy 逻辑一致。
|
||||
*/
|
||||
window.ensureAndPokeChanStudy = function (chart) {
|
||||
try {
|
||||
var studies = chart.getAllStudies ? chart.getAllStudies() : []
|
||||
var existingId = null
|
||||
for (var i = 0; i < studies.length; i++) {
|
||||
if (studies[i].name === CHAN_STUDY_DESC) {
|
||||
existingId = studies[i].id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
chanEpoch += 1
|
||||
|
||||
if (existingId) {
|
||||
try {
|
||||
var api = chart.getStudyById(existingId)
|
||||
if (api && api.setInputValues) {
|
||||
api.setInputValues([{ id: 'epoch', value: chanEpoch }])
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('setInputValues Chan failed', err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 新建 study — 必须是 chart.createStudy(...) 保持 this 绑定!
|
||||
if (!chart.createStudy) return
|
||||
var result = chart.createStudy(CHAN_STUDY_DESC, false, false, { epoch: chanEpoch })
|
||||
// createStudy 返回 Promise<string>
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(function (id) {
|
||||
if (!id) {
|
||||
console.warn('[缠论] createStudy 返回空 id(指标未注册成功)')
|
||||
return
|
||||
}
|
||||
console.log('[缠论] study 已创建', id)
|
||||
try {
|
||||
var studyApi = chart.getStudyById(id)
|
||||
if (studyApi && studyApi.bringToFront) studyApi.bringToFront()
|
||||
} catch (err) {
|
||||
console.warn('bringToFront Chan failed', err)
|
||||
}
|
||||
}).catch(function (err) {
|
||||
console.warn('createStudy Chan failed', err)
|
||||
})
|
||||
} else if (result) {
|
||||
// 同步返回(兜底)
|
||||
console.log('[缠论] study 已创建 (sync)', result)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('ensureAndPokeChanStudy error', e)
|
||||
}
|
||||
}
|
||||
})()
|
||||
/* deprecated path: use /static/js/app/chan_indicator.js */
|
||||
(function(){
|
||||
var s=document.createElement('script'); s.src='/static/js/app/chan_indicator.js';
|
||||
document.currentScript.parentNode.insertBefore(s, document.currentScript.nextSibling);
|
||||
})();
|
||||
|
||||
@@ -1,306 +1,5 @@
|
||||
/**
|
||||
* TradingView Datafeed — 对接 Data Provider 微服务
|
||||
*
|
||||
* 数据源: http://103.179.242.166
|
||||
* - GET /timeframes → 可用周期
|
||||
* - GET /api/candles → 历史 OHLCV
|
||||
* - WS /ws → 实时 K 线推送
|
||||
*
|
||||
* 实现 IDatafeedChartApi 核心接口:
|
||||
* onReady, resolveSymbol, getBars, subscribeBars, unsubscribeBars
|
||||
*/
|
||||
|
||||
var ChanTVDatafeed = (function () {
|
||||
'use strict'
|
||||
|
||||
// 默认 data_provider 地址,可通过 URL param 覆盖
|
||||
var DATA_HOST = 'http://103.179.242.166'
|
||||
|
||||
// ---- resolution <-> timeframe 转换 ----
|
||||
var RES_TO_TF = {
|
||||
'1': '1m', '3': '3m', '5': '5m', '10': '10m', '15': '15m', '30': '30m',
|
||||
'60': '1h', '120': '2h', '240': '4h', '360': '6h', '480': '8h',
|
||||
'720': '12h',
|
||||
'D': '1d', '1D': '1d',
|
||||
'3D': '3d',
|
||||
'W': '1w', '1W': '1w',
|
||||
'M': '1M', '1M': '1M',
|
||||
}
|
||||
|
||||
function resToTf(resolution) {
|
||||
var r = String(resolution)
|
||||
return RES_TO_TF[r] || r
|
||||
}
|
||||
|
||||
// ---- WebSocket 管理 ----
|
||||
var ws = null
|
||||
var wsReconnectTimer = null
|
||||
var wsSubs = {} // listenerGuid -> { symbol, tf, onTick, lastTickTime }
|
||||
var wsUrl = DATA_HOST.replace(/^http/, 'ws') + '/ws'
|
||||
|
||||
function wsConnect() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl)
|
||||
} catch (e) {
|
||||
console.warn('[TV Datafeed] WS 连接失败', e)
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
ws.onopen = function () {
|
||||
console.log('[TV Datafeed] WS 已连接')
|
||||
// 重新订阅
|
||||
Object.keys(wsSubs).forEach(function (guid) {
|
||||
var sub = wsSubs[guid]
|
||||
sendWS({ action: 'subscribe', symbol: sub.symbol, timeframe: sub.tf })
|
||||
})
|
||||
}
|
||||
|
||||
ws.onmessage = function (evt) {
|
||||
try {
|
||||
var msg = JSON.parse(evt.data)
|
||||
var bars = msg.data || msg.bars // data_provider 用 'data' 字段
|
||||
if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) {
|
||||
// 只推送最新一根 bar,避免历史快照造成时间顺序冲突
|
||||
// 按时间升序排列取最后一个
|
||||
var sorted = bars.slice().sort(function (a, b) { return (a.timestamp || 0) - (b.timestamp || 0) })
|
||||
var latest = sorted[sorted.length - 1]
|
||||
// 广播给所有匹配的 subscriber
|
||||
Object.keys(wsSubs).forEach(function (guid) {
|
||||
var sub = wsSubs[guid]
|
||||
if (sub.symbol === msg.symbol && sub.tf === msg.timeframe) {
|
||||
// 跳过已处理过的时间戳
|
||||
if (sub.lastTickTime && latest.timestamp <= sub.lastTickTime) return
|
||||
try {
|
||||
sub.onTick({
|
||||
time: latest.timestamp,
|
||||
open: latest.open,
|
||||
high: latest.high,
|
||||
low: latest.low,
|
||||
close: latest.close,
|
||||
volume: latest.volume,
|
||||
})
|
||||
sub.lastTickTime = latest.timestamp
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = function () {
|
||||
console.log('[TV Datafeed] WS 断开')
|
||||
ws = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = function () {
|
||||
// onclose 会跟着触发
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (wsReconnectTimer) return
|
||||
wsReconnectTimer = setTimeout(function () {
|
||||
wsReconnectTimer = null
|
||||
wsConnect()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function sendWS(data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Datafeed API ----
|
||||
|
||||
/**
|
||||
* 主配置:返回支持的 resolutions、exchanges 等
|
||||
*/
|
||||
function onReady(callback) {
|
||||
// 使用固定 resolutions(避免 /timeframes 502 阻塞初始化)
|
||||
var supported = ['1', '5', '15', '30', '60', '120', '240', 'D', 'W']
|
||||
console.log('[TV Datafeed] onReady — supported_resolutions:', supported)
|
||||
|
||||
setTimeout(function () {
|
||||
callback({
|
||||
supported_resolutions: supported,
|
||||
supports_marks: false,
|
||||
supports_timescale_marks: false,
|
||||
supports_time: true,
|
||||
exchanges: [{ value: 'BINANCE', name: 'Binance', desc: 'Binance Futures' }],
|
||||
symbols_types: [{ name: 'Crypto', value: 'crypto' }],
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 symbol:'BINANCE:BTC/USDT:USDT' → 分离 exchange 和 symbol
|
||||
*/
|
||||
function resolveSymbol(symbolName, onResolve, onError) {
|
||||
var name = String(symbolName)
|
||||
var exchange = 'BINANCE'
|
||||
var symbol = name
|
||||
|
||||
// 解析 EXCHANGE:SYMBOL 格式
|
||||
// 如果第一段不含 '/',就是交易所名;否则整串就是 symbol
|
||||
// 例: 'BINANCE:BTC/USDT:USDT' → exchange=BINANCE, sym=BTC/USDT:USDT
|
||||
// 'BTC/USDT:USDT' → exchange=BINANCE, sym=BTC/USDT:USDT
|
||||
// 'BTC/USDT' → exchange=BINANCE, sym=BTC/USDT:USDT
|
||||
var firstColon = name.indexOf(':')
|
||||
if (firstColon >= 0) {
|
||||
var prefix = name.substring(0, firstColon)
|
||||
if (prefix.indexOf('/') === -1) {
|
||||
// 第一段是交易所名(如 'BINANCE')
|
||||
exchange = prefix
|
||||
symbol = name.substring(firstColon + 1)
|
||||
}
|
||||
// 否则第一段含 '/'(如 'BTC/USDT'),整串就是 symbol
|
||||
}
|
||||
|
||||
// data_provider 用 BTC/USDT:USDT 格式(需要 :USDT 后缀)
|
||||
var dpSymbol = symbol
|
||||
if (dpSymbol.indexOf(':USDT') === -1 && dpSymbol.indexOf('/USDT') >= 0) {
|
||||
dpSymbol = dpSymbol + ':USDT'
|
||||
}
|
||||
|
||||
console.log('[TV Datafeed] resolveSymbol', name, '→ exchange:', exchange, 'symbol:', symbol, 'dp:', dpSymbol)
|
||||
|
||||
// TV 要求异步回调(setTimeout 0)
|
||||
setTimeout(function () {
|
||||
onResolve({
|
||||
name: name,
|
||||
ticker: name,
|
||||
description: symbol,
|
||||
exchange: exchange,
|
||||
type: 'crypto',
|
||||
session: '24x7',
|
||||
timezone: 'Asia/Shanghai',
|
||||
minmov: 1,
|
||||
pricescale: 100,
|
||||
has_intraday: true,
|
||||
has_seconds: false,
|
||||
has_daily: true,
|
||||
has_weekly_and_monthly: true,
|
||||
supported_resolutions: ['1', '5', '15', '30', '60', '120', '240', 'D', 'W'],
|
||||
intraday_multipliers: ['1', '5', '15', '30', '60', '120', '240'],
|
||||
volume_precision: 2,
|
||||
_dpSymbol: dpSymbol,
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史 bars
|
||||
*/
|
||||
function getBars(symbolInfo, resolution, periodParams, onResult, onError) {
|
||||
var tf = resToTf(resolution)
|
||||
var symbol = symbolInfo._dpSymbol || symbolInfo.ticker.split(':').slice(1).join(':')
|
||||
// 确保 symbol 是 data_provider 格式
|
||||
if (symbol.indexOf(':USDT') === -1 && symbol.indexOf('/USDT') >= 0) {
|
||||
symbol = symbol + ':USDT'
|
||||
}
|
||||
|
||||
var params = 'symbol=' + encodeURIComponent(symbol) + '&tf=' + encodeURIComponent(tf)
|
||||
|
||||
// periodParams.from / to 是秒,data_provider 需要毫秒
|
||||
if (periodParams.from) {
|
||||
params += '&start=' + (periodParams.from * 1000)
|
||||
}
|
||||
if (periodParams.to) {
|
||||
params += '&end=' + (periodParams.to * 1000)
|
||||
}
|
||||
if (periodParams.firstDataRequest) {
|
||||
// 首次请求多取一些数据供缠论计算
|
||||
params += '&limit=1000'
|
||||
}
|
||||
|
||||
var url = DATA_HOST + '/api/candles?' + params
|
||||
console.log('[TV Datafeed] getBars', symbol, tf, '→', url)
|
||||
|
||||
fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status)
|
||||
return r.json()
|
||||
})
|
||||
.then(function (data) {
|
||||
console.log('[TV Datafeed] getBars 返回', data.length, '条')
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
onResult([], { noData: true })
|
||||
return
|
||||
}
|
||||
|
||||
// 按时间升序排列并去重,避免跨请求重叠导致时间顺序冲突
|
||||
var seen = {}
|
||||
var bars = []
|
||||
data.forEach(function (d) {
|
||||
if (!seen[d.timestamp]) {
|
||||
seen[d.timestamp] = true
|
||||
bars.push({
|
||||
time: d.timestamp, // ms
|
||||
open: d.open,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
close: d.close,
|
||||
volume: d.volume,
|
||||
})
|
||||
}
|
||||
})
|
||||
bars.sort(function (a, b) { return a.time - b.time })
|
||||
|
||||
// 传 noData: false 表示还有更多历史数据
|
||||
onResult(bars, { noData: false })
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error('[TV Datafeed] getBars 失败', err)
|
||||
onError(err.message || '获取数据失败')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅实时数据(通过 WebSocket)
|
||||
*/
|
||||
function subscribeBars(symbolInfo, resolution, onTick, listenerGuid) {
|
||||
var tf = resToTf(resolution)
|
||||
var symbol = symbolInfo._dpSymbol || symbolInfo.ticker.split(':').slice(1).join(':')
|
||||
if (symbol.indexOf(':USDT') === -1 && symbol.indexOf('/USDT') >= 0) {
|
||||
symbol = symbol + ':USDT'
|
||||
}
|
||||
|
||||
wsSubs[listenerGuid] = { symbol: symbol, tf: tf, onTick: onTick }
|
||||
|
||||
// 确保 WS 已连接
|
||||
wsConnect()
|
||||
|
||||
// 如果已连接,立即订阅
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
sendWS({ action: 'subscribe', symbol: symbol, timeframe: tf })
|
||||
}
|
||||
// 否则等 WS onopen 时会重新订阅所有
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
*/
|
||||
function unsubscribeBars(listenerGuid) {
|
||||
var sub = wsSubs[listenerGuid]
|
||||
if (sub) {
|
||||
sendWS({ action: 'unsubscribe', symbol: sub.symbol, timeframe: sub.tf })
|
||||
delete wsSubs[listenerGuid]
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 导出 ----
|
||||
return {
|
||||
onReady: onReady,
|
||||
resolveSymbol: resolveSymbol,
|
||||
getBars: getBars,
|
||||
subscribeBars: subscribeBars,
|
||||
unsubscribeBars: unsubscribeBars,
|
||||
}
|
||||
})()
|
||||
/* deprecated path: use /static/js/app/datafeed.js */
|
||||
(function(){
|
||||
var s=document.createElement('script'); s.src='/static/js/app/datafeed.js';
|
||||
document.currentScript.parentNode.insertBefore(s, document.currentScript.nextSibling);
|
||||
})();
|
||||
|
||||
@@ -147,9 +147,10 @@
|
||||
<!-- TV charting library -->
|
||||
<script src="/charting_library/charting_library.js"></script>
|
||||
<!-- 自定义模块 -->
|
||||
<script src="/static/js/chan_engine.js?v=3"></script>
|
||||
<script src="/static/js/tv_datafeed.js?v=5"></script>
|
||||
<script src="/static/js/chan_indicator.js?v=9"></script>
|
||||
<script src="/static/js/app/api_client.js?v=1"></script>
|
||||
<script src="/static/js/app/chan_engine.js?v=4"></script>
|
||||
<script src="/static/js/app/datafeed.js?v=6"></script>
|
||||
<script src="/static/js/app/chan_indicator.js?v=10"></script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
+13
-9312
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
""" /api/analyze 契约冒烟:关键字段存在于契约清单。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "web"))
|
||||
|
||||
|
||||
def test_analyze_route_registered():
|
||||
from app import app
|
||||
|
||||
rules = {r.rule for r in app.url_map.iter_rules()}
|
||||
assert "/api/analyze" in rules
|
||||
assert "/api/chart_metadata" in rules
|
||||
assert "/" in rules
|
||||
assert "/chan_tv" in rules
|
||||
|
||||
|
||||
def test_contract_keys_stable():
|
||||
keys = json.loads(
|
||||
(ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert "bi_list" in keys and "seg_list" in keys
|
||||
Reference in New Issue
Block a user