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:
jackyu66git
2026-08-05 18:48:20 +08:00
co-authored by Cursor
parent e2e45bc1bc
commit 74dec4e50b
160 changed files with 25576 additions and 23104 deletions
View File
+660
View File
@@ -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)
+73
View File
@@ -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
+101
View File
@@ -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
})
+126
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+3 -966
View File
@@ -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
+31
View File
@@ -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"))
View File
+10
View File
@@ -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,
)
+966
View File
@@ -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
+14
View File
@@ -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
+8
View File
@@ -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,
)
+11
View File
@@ -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,
)
+21
View File
@@ -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
+496
View File
@@ -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)
}
}
})()
+1
View File
@@ -0,0 +1 @@
/* chart.js split into chart_format/view/tv/sync/tables — see index.html load order */
+85
View File
@@ -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>';
}
+712
View File
@@ -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();
}
}
+356
View File
@@ -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
+148
View File
@@ -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) {}
});
}
}
// 初始化图表
+306
View File
@@ -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 ----
/**
* 主配置返回支持的 resolutionsexchanges
*/
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,
}
})()
+258
View File
@@ -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';
+443
View File
@@ -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
+16
View File
@@ -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;
+571
View File
@@ -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();
});
+826
View File
@@ -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系列
File diff suppressed because it is too large Load Diff
+5 -496
View File
@@ -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);
})();
+5 -306
View File
@@ -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 ----
/**
* 主配置返回支持的 resolutionsexchanges
*/
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);
})();
+4 -3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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