研究侧的 fast_bsp3 一直只活在 research/lib/ 里,web 端看不到,回测与目视 两条线对不上。这次把它搬进引擎,作为独立的第四类买卖点。 之所以单独立类而不是当作 B3/S3 的低滞后版:step30/31 显示引擎原生的 B3/S3 统计上呈逆势、显著亏损(胜率 27.4%、PF 0.66、t −18.76),而同一组 过滤器把 B4 从 PF 1.59 提到 2.26 却对它无效(0.66→0.71)。两者选的是 不同的交易群体,不是同一信号的早晚两版。 - chanlun/analysis/fast_bsp.py 原样搬入 find_fast_bsp3 与 build_htf_zones, 另加 add_zone_ladder / htf_fx_timeline / attach_htf_agree - research/lib/ 两个模块改为转发,所有 step 脚本导入不变,信号逐条比对一致 - 大级别上下文用 resample 从同一份 df 构建,不额外拉数据,因此与界面上选的 周期和时间范围无关 - 前端三个复选框 + 过滤模式下拉;未过滤的原始信号用浅色,避免与主口径混淆 Co-authored-by: Cursor <cursoragent@cursor.com>
743 lines
36 KiB
Python
743 lines
36 KiB
Python
"""分析 API。"""
|
||
from flask import Blueprint, jsonify, request
|
||
from services.runtime import * # noqa: F403
|
||
from services import runtime as R
|
||
# import * 不会带出下划线私有名;结构区缓存需显式导入
|
||
from services.runtime.state import _zone_cache
|
||
from services.runtime.timeframes import _zone_cache_ttl
|
||
|
||
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', [])],
|
||
# 添加主周期第四类买卖点(B4/S4,低滞后三类买卖点)
|
||
'fast_bsp_list': serialize_fast_bsp_list(analysis_result.get('fast_bsp_list', []), client_tz)
|
||
})
|
||
|
||
|
||
# 如果有指定分形元素时间周期,获取小周期数据
|
||
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', [])]
|
||
result['element_fast_bsp_list'] = serialize_fast_bsp_list(element_analysis.get('fast_bsp_list', []), client_tz)
|
||
|
||
# 次次周期:仅当已指定次周期且次次周期有效时获取
|
||
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_fast_bsp_list'] = serialize_fast_bsp_list(sub_sub_analysis.get('fast_bsp_list', []), client_tz)
|
||
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)
|
||
|
||
|
||
def _serialize_kl_tail(df, limit: int):
|
||
"""只序列化最近 limit 根,供自动刷新增量合并。"""
|
||
if df is None or getattr(df, "empty", True):
|
||
return []
|
||
tail = df.tail(limit)
|
||
clean = clean_dataframe_for_json(tail)
|
||
records = clean.to_dict("records")
|
||
for row in records:
|
||
d = row.get("date")
|
||
if hasattr(d, "isoformat"):
|
||
try:
|
||
row["date"] = d.isoformat()
|
||
except Exception:
|
||
row["date"] = str(d)
|
||
# timestamp 统一成 int ms,便于前端按 key 合并
|
||
ts = row.get("timestamp")
|
||
if ts is not None:
|
||
try:
|
||
row["timestamp"] = int(ts)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
elif hasattr(d, "timestamp"):
|
||
try:
|
||
row["timestamp"] = int(d.timestamp() * 1000)
|
||
except Exception:
|
||
pass
|
||
return records
|
||
|
||
|
||
@bp.route("/api/klines/recent")
|
||
def klines_recent():
|
||
"""轻量拉取最近 N 根 K 线(不做缠论/威科夫),供主站自动刷新增量。"""
|
||
symbol = (request.args.get("symbol") or "").strip()
|
||
if not symbol:
|
||
return jsonify({"error": "交易对不能为空"}), 400
|
||
|
||
timeframe = request.args.get("timeframe", "5m")
|
||
try:
|
||
limit = int(request.args.get("limit", 2))
|
||
except (TypeError, ValueError):
|
||
limit = 2
|
||
limit = max(1, min(limit, 20))
|
||
|
||
element_timeframe = request.args.get("element_timeframe") or None
|
||
sub_sub_timeframe = request.args.get("sub_sub_timeframe") or None
|
||
|
||
# 只取尾部:不传 start/end,避免全量窗口回拉
|
||
df = get_kl_data(symbol, timeframe, limit=limit)
|
||
if df is None:
|
||
return jsonify({"error": "获取数据失败"}), 502
|
||
if len(df) == 0:
|
||
return jsonify({"error": "没有数据"}), 404
|
||
|
||
result = {
|
||
"partial": True,
|
||
"symbol": symbol,
|
||
"timeframe": timeframe,
|
||
"limit": limit,
|
||
"kline_data": _serialize_kl_tail(df, limit),
|
||
}
|
||
|
||
if element_timeframe:
|
||
edf = get_kl_data(symbol, element_timeframe, limit=limit)
|
||
result["element_timeframe"] = element_timeframe
|
||
result["element_kline_data"] = _serialize_kl_tail(edf, limit) if edf is not None else []
|
||
|
||
if sub_sub_timeframe:
|
||
sdf = get_kl_data(symbol, sub_sub_timeframe, limit=limit)
|
||
result["sub_sub_timeframe"] = sub_sub_timeframe
|
||
result["sub_sub_kline_data"] = _serialize_kl_tail(sdf, limit) if sdf is not None else []
|
||
|
||
return jsonify(result)
|
||
|