fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新

自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-25 22:57:43 +08:00
co-authored by Cursor
parent 1e60ab3bfa
commit 8ee11317d3
104 changed files with 21452 additions and 4988 deletions
+201 -25
View File
@@ -2,9 +2,103 @@
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__)
_WYCKOFF_EMPTY = {
'trading_range': None,
'bias': 'unknown',
'phases': [],
'events': [],
'volume_profile': {'bins': [], 'poc': None, 'vah': None, 'val': None, 'bin_count': 0},
'volume_confirm': {'avg_volume': 0.0, 'event_checks': {}},
'cycles': [],
'live': None,
'lifecycle': 'UNKNOWN',
}
def _localize_wyckoff_payload(w, client_tz):
"""把威科夫时间统一成客户端时区 ISO,便于与主图对齐。"""
if not w:
return w
def _loc_tr(tr):
if not tr:
return
tr['start_time'] = format_time_safely(tr.get('start_time'), client_tz) or tr.get('start_time')
tr['end_time'] = format_time_safely(tr.get('end_time'), client_tz) or tr.get('end_time')
def _loc_cycle(c):
if not c:
return
per = c.get('period') or {}
per['start_time'] = format_time_safely(per.get('start_time'), client_tz) or per.get('start_time')
per['end_time'] = format_time_safely(per.get('end_time'), client_tz) or per.get('end_time')
c['period'] = per
_loc_tr(c.get('trading_range'))
for ph in c.get('phases') or []:
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
for ev in c.get('events') or []:
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
_loc_tr(w.get('trading_range'))
for ph in w.get('phases') or []:
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
for ev in w.get('events') or []:
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
for c in w.get('cycles') or []:
_loc_cycle(c)
return w
def _compute_wyckoff_from_df(df, tf, vp_bins, client_tz=None, range_start_time=None, prefer_start_time=None):
"""直接用该周期已有 DataFrame(与缠论同一份)。
搜索窗口 = 整段数据;箱体在窗内评分选取(近优分取更长),
次/次次可用 prefer_start_time 对齐主箱起点。
"""
from chanlun.analysis.wyckoff import analyze_wyckoff
try:
if df is None or len(df) < 30:
empty = dict(_WYCKOFF_EMPTY)
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
empty['timeframe'] = tf
return empty
lookback = len(df)
min_bars = max(24, min(80, lookback // 12))
out = analyze_wyckoff(
df,
lookback=lookback,
vp_bins=vp_bins,
min_bars=min_bars,
range_start_time=range_start_time,
prefer_start_time=prefer_start_time,
)
out['timeframe'] = tf
out['lookback'] = lookback
out['min_bars'] = min_bars
if client_tz is not None:
_localize_wyckoff_payload(out, client_tz)
return out
except Exception as e:
print(f"Wyckoff 分析出错 ({tf}): {e}")
import traceback
traceback.print_exc()
empty = dict(_WYCKOFF_EMPTY)
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
empty['timeframe'] = tf
empty['error'] = str(e)
return empty
@bp.route('/api/analyze')
def analyze():
"""分析接口"""
@@ -25,6 +119,9 @@ def analyze():
# 获取分形元素时间周期与次次周期
element_timeframe = request.args.get('element_timeframe')
sub_sub_timeframe = request.args.get('sub_sub_timeframe')
# 供文末三周期威科夫复用(避免重复拉数)
element_df_for_wyckoff = None
sub_sub_df_for_wyckoff = None
# 获取是否只需要分形元素数据的参数
elements_only_param = request.args.get('elements_only')
@@ -249,6 +346,7 @@ def analyze():
if element_df is not None and len(element_df) > 0:
# 添加小周期技术指标(包括布林带)
element_df = add_indicators(element_df)
element_df_for_wyckoff = element_df
# 对小周期数据进行缠论分析
element_analysis = analyze_chan(element_df, symbol, element_timeframe)
@@ -427,6 +525,7 @@ def analyze():
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_df_for_wyckoff = 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')
@@ -656,33 +755,110 @@ def analyze():
else:
result['structure_zones'] = []
# 威科夫分析 —— 按需:include_wyckoff=1,且须有主周期分析(非 elements_only
include_wyckoff_param = request.args.get('include_wyckoff', '')
include_wyckoff = str(include_wyckoff_param).lower() in ('1', 'true', 'yes')
# 威科夫:主 / 次 / 次次各算一份(非 elements_only;前端开关只控制绘制
# include_wyckoff=0 可显式跳过;缺省与其它真值均计算
include_wyckoff_param = request.args.get('include_wyckoff', '1')
include_wyckoff = str(include_wyckoff_param).lower() not in ('0', 'false', 'no')
if include_wyckoff and not elements_only:
try:
from chanlun.analysis.wyckoff import analyze_wyckoff
wyckoff_lookback = int(request.args.get('wyckoff_lookback', 120))
# ECR-004:默认/上限 24 binsA+C
wyckoff_bins = int(request.args.get('wyckoff_vp_bins', 24))
result['wyckoff'] = analyze_wyckoff(
df,
lookback=max(40, min(wyckoff_lookback, 500)),
vp_bins=max(10, min(wyckoff_bins, 24)),
# 主周期先算;次/次次只同步 active=cycles[0] 的 startWYCKOFF-MULTI-CYCLE-001
wyckoff_bins = max(10, min(int(request.args.get('wyckoff_vp_bins', 24)), 24))
result['wyckoff'] = _compute_wyckoff_from_df(df, timeframe, wyckoff_bins, client_tz=None)
main_w = result.get('wyckoff') or {}
cycles = main_w.get('cycles') or []
# active 唯一来源 cycles[0];禁止 cycles[-1]
active = cycles[0] if cycles else None
prefer_start = None
if active:
prefer_start = ((active.get('trading_range') or {}).get('start_time')
or (active.get('period') or {}).get('start_time'))
elif main_w.get('trading_range'):
prefer_start = main_w['trading_range'].get('start_time')
if client_tz is not None:
_localize_wyckoff_payload(result['wyckoff'], client_tz)
if element_timeframe:
result['element_wyckoff'] = _compute_wyckoff_from_df(
element_df_for_wyckoff, element_timeframe, wyckoff_bins, client_tz,
prefer_start_time=prefer_start,
)
if sub_sub_timeframe:
result['sub_sub_wyckoff'] = _compute_wyckoff_from_df(
sub_sub_df_for_wyckoff, sub_sub_timeframe, wyckoff_bins, client_tz,
prefer_start_time=prefer_start,
)
except Exception as e:
print(f"Wyckoff 分析出错: {e}")
import traceback
traceback.print_exc()
result['wyckoff'] = {
'trading_range': None,
'bias': 'unknown',
'phases': [],
'events': [],
'volume_profile': {'bins': [], 'poc': None, 'vah': None, 'val': None, 'bin_count': 0},
'volume_confirm': {'avg_volume': 0.0, 'event_checks': {}},
'error': str(e),
}
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)