From 9f1e7361b67aba766811ad7643068aaa220e6aa5 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Thu, 6 Aug 2026 16:09:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=BB=E7=AB=99?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0=E5=86=85=E5=AD=98=E6=B3=84?= =?UTF-8?q?=E6=BC=8F=EF=BC=8C=E5=B9=B6=E5=AE=8C=E5=96=84=20chan=5Ftv=20?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主站重建前完整 dispose、去掉重复 sync 监听,自动刷新默认增量更新;顺带消除首屏重复 analyze、复用 ChanMACD,以及全版 TV 指标/未完成中枢/布局本地缓存。 Co-authored-by: Cursor --- chanlun/pipeline/builders/kline.py | 4 +- chanlun/pipeline/timeframe.py | 7 +- web/api/pages.py | 7 +- web/config.py | 5 + web/services/runtime.py | 6 +- web/static/js/app/chan_engine.js | 83 +++-- web/static/js/app/chan_indicator.js | 61 +++- web/static/js/app/chart_sync.js | 5 +- web/static/js/app/chart_tv.js | 325 ++++--------------- web/static/js/app/chart_view.js | 26 +- web/static/js/app/datafeed.js | 61 ++-- web/static/js/app/ui.js | 44 ++- web/templates/chan_tv.html | 484 ++++++++++------------------ web/templates/index.html | 8 +- 14 files changed, 457 insertions(+), 669 deletions(-) diff --git a/chanlun/pipeline/builders/kline.py b/chanlun/pipeline/builders/kline.py index 74a7d6b..6330179 100644 --- a/chanlun/pipeline/builders/kline.py +++ b/chanlun/pipeline/builders/kline.py @@ -174,8 +174,10 @@ class KlineBuilderMixin: def get_klc_list(self, klu_list): klc_list = [] last_klu = None + # ChanMACD.__init__ 已调用 cal_macd_state,切勿再调一次(会重复堆积 seg/unittf) macd = ChanMACD(klu_list) - klu_list = macd.cal_macd_state() + klu_list = macd.klu_list + self._last_chan_macd = macd ema_up_list = [] ema_down_list = [] ema_up_count = 0 diff --git a/chanlun/pipeline/timeframe.py b/chanlun/pipeline/timeframe.py index e00cfd5..f837f6b 100644 --- a/chanlun/pipeline/timeframe.py +++ b/chanlun/pipeline/timeframe.py @@ -68,8 +68,11 @@ class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilde self.seg_list = self.get_seg_list(self.bi_list) self.zs_list = self.get_zs_list(self.bi_list, self.seg_list) self.big_zs_list = self.get_big_zs_list(self.zs_list) - self.chanmacd = ChanMACD(self.klu_list) - self.klu_list = self.chanmacd.cal_macd_state() + # get_klc_list 内已算过 ChanMACD,直接复用 + self.chanmacd = getattr(self, '_last_chan_macd', None) + if self.chanmacd is None: + self.chanmacd = ChanMACD(self.klu_list) + self.klu_list = self.chanmacd.klu_list def get_current_klc(self): diff --git a/web/api/pages.py b/web/api/pages.py index 1dacb01..20c03bc 100644 --- a/web/api/pages.py +++ b/web/api/pages.py @@ -1,5 +1,6 @@ """页面路由。""" from flask import Blueprint, render_template, send_from_directory +from config import DATA_SERVICE_URL, DATA_SERVICE_WS_URL from services.runtime import * # noqa: F403 from services import runtime as R @@ -8,7 +9,11 @@ bp = Blueprint("pages", __name__) @bp.route('/chan_tv') def chan_tv(): """缠论 TradingView 高级图表页面""" - return render_template('chan_tv.html') + return render_template( + 'chan_tv.html', + data_service_url=DATA_SERVICE_URL, + data_service_ws_url=DATA_SERVICE_WS_URL, + ) @bp.route('/charting_library/') def serve_charting_library(filename): diff --git a/web/config.py b/web/config.py index 83b968d..7ebc7d4 100644 --- a/web/config.py +++ b/web/config.py @@ -7,6 +7,11 @@ DATA_SERVICE_URL = os.environ.get( "DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"), ) +# WebSocket 与 REST 可能不同域名(nginx 反代) +DATA_SERVICE_WS_URL = os.environ.get( + "DATA_SERVICE_WS_URL", + "wss://jackyu66.com/ws", +) ASHARE_DP_URL = os.environ.get("ASHARE_DP_URL", "http://103.179.242.166:8000") # HTTP 代理:未设置则不走代理;可设 HTTP_PROXY/HTTPS_PROXY 或 CHAN_HTTP_PROXY diff --git a/web/services/runtime.py b/web/services/runtime.py index 553110a..c66c1e6 100644 --- a/web/services/runtime.py +++ b/web/services/runtime.py @@ -618,14 +618,16 @@ def analyze_chan(df, symbol=None, timeframe=None): bi.cal_macd_div() #print(bi.start_time, bi.macd_hist, bi.macd_div) - # 添加ChanMACD分析 + # 添加ChanMACD分析(复用 get_klc_list 内已算好的结果,避免同周期二次全量分析) chan_macd = None chan_macd_data = {} try: if klu_list and len(klu_list) > 0: print(f"获取到KLU列表,长度: {len(klu_list)}") - chan_macd = ChanMACD(klu_list) + chan_macd = getattr(chan, '_last_chan_macd', None) + if chan_macd is None: + chan_macd = ChanMACD(klu_list) chan_macd_data = { 'seg_list': chan_macd.seg_list, 'unittf_list': chan_macd.unittf_list, diff --git a/web/static/js/app/chan_engine.js b/web/static/js/app/chan_engine.js index b8e3716..4141cbd 100644 --- a/web/static/js/app/chan_engine.js +++ b/web/static/js/app/chan_engine.js @@ -981,19 +981,41 @@ function findBiCenters(biList) { var lows = biList_for_zs.map(function(bi) { return Math.min(bi.p0, bi.p1) }) gg = Math.max.apply(null, highs) dd = Math.min.apply(null, lows) - endBiIdx = startIdx + addedAfterLeave.length + endBiIdx = startIdx + 2 + addedAfterLeave.length + } + + var lastBiInCenter = biList_for_zs[biList_for_zs.length - 1] + // 是否已离开中枢:之后出现完全在 ZG 之上或 ZD 之下的确认笔 → 中枢完成 + var zsSure = false + var lastInListIdx = -1 + for (var li = 0; li < biList.length; li++) { + if (biList[li] === lastBiInCenter || (biList[li].t0 === lastBiInCenter.t0 && biList[li].t1 === lastBiInCenter.t1)) { + lastInListIdx = li + break + } + } + if (lastInListIdx < 0) lastInListIdx = endBiIdx + for (var j = lastInListIdx + 1; j < biList.length; j++) { + var leaveBi = biList[j] + if (!leaveBi.sure) break + var lbh = Math.max(leaveBi.p0, leaveBi.p1) + var lbl = Math.min(leaveBi.p0, leaveBi.p1) + if (lbl > zg || lbh < zd) { + zsSure = true + break + } } var zs = { t0: bi1.t0, - t1: biList_for_zs[biList_for_zs.length - 1].t1, + t1: lastBiInCenter.t1, high: zg, low: zd, zg: zg, zd: zd, gg: gg, dd: dd, - is_sure: biList_for_zs[biList_for_zs.length - 1].sure, + is_sure: zsSure, bi_count: biList_for_zs.length, - bi_list: biList_for_zs, // 中枢内的笔列表(按序) - start_bi_idx: startIdx, // 中枢首笔在总列表中的索引 + bi_list: biList_for_zs, + start_bi_idx: startIdx, dir: zsDir, pre: lastZs, next: null, @@ -1008,28 +1030,6 @@ function findBiCenters(biList) { startIdx = startIdx + 4 + (addedAfterLeave.length > 0 ? addedAfterLeave.length : 0) } - // 末中枢确认 - if (lastZs && !lastZs.is_sure) { - var lastBiInZs = lastZs.bi_count > 0 ? biList_for_zs[biList_for_zs.length - 1] : null - if (lastBiInZs) { - var hasLeave = false - var lastBiIdx = biList.indexOf(lastBiInZs) - if (lastBiIdx >= 0) { - for (var i = lastBiIdx + 1; i < biList.length; i++) { - var bi = biList[i] - if (bi.sure) { - var bh = Math.max(bi.p0, bi.p1), bl = Math.min(bi.p0, bi.p1) - var leave = (bl > lastZs.zg && bh > lastZs.zg) || (bh < lastZs.zd && bl < lastZs.zd) - if (leave) { hasLeave = true; break } - } - } - } - if (hasLeave && lastBiInZs.sure) { - lastZs.t1 = lastBiInZs.t1 - } - } - } - return zsList } @@ -1119,16 +1119,37 @@ function findSegCenters(segs) { endSegIdx = startIdx + 2 + addedSegs.length } + var lastSegInCenter = segList_for_zs[segList_for_zs.length - 1] + var zsSure = false + var lastSegListIdx = -1 + for (var lsi = 0; lsi < segs.length; lsi++) { + if (segs[lsi] === lastSegInCenter || (segs[lsi].t0 === lastSegInCenter.t0 && segs[lsi].t1 === lastSegInCenter.t1)) { + lastSegListIdx = lsi + break + } + } + if (lastSegListIdx < 0) lastSegListIdx = endSegIdx + for (var sj = lastSegListIdx + 1; sj < segs.length; sj++) { + var leaveSeg = segs[sj] + if (!leaveSeg.sure) break + var lsh = Math.max(leaveSeg.p0, leaveSeg.p1) + var lsl = Math.min(leaveSeg.p0, leaveSeg.p1) + if (lsl > zg || lsh < zd) { + zsSure = true + break + } + } + var zs = { t0: s1.t0, - t1: segs[endSegIdx].t1, + t1: lastSegInCenter.t1, high: zg, low: zd, zg: zg, zd: zd, gg: gg, dd: dd, - is_sure: segs[endSegIdx].sure, + is_sure: zsSure, seg_count: segList_for_zs.length, - seg_list: segList_for_zs, // 中枢内的段列表(按序) - start_seg_idx: startIdx, // 中枢首段在总列表中的索引 + seg_list: segList_for_zs, + start_seg_idx: startIdx, dir: zsDir, pre: lastZs, next: null, diff --git a/web/static/js/app/chan_indicator.js b/web/static/js/app/chan_indicator.js index d8e9383..901bcfd 100644 --- a/web/static/js/app/chan_indicator.js +++ b/web/static/js/app/chan_indicator.js @@ -121,6 +121,7 @@ } // 中枢填充:区间内每根 bar 写入 top/bottom + // 未完成中枢:右边界拉到最新 K(与主站 uncompleted_zs 一致) function fillZs(t0, t1, high, low, topField, botField) { var lo = lowerBound(sortedBarTimes, t0) var hi = upperBound(sortedBarTimes, t1) @@ -131,14 +132,30 @@ } } + var lastBarT = sortedBarTimes.length ? sortedBarTimes[sortedBarTimes.length - 1] : null + 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') + var t1 = z.t1 + var sure = z.is_sure !== false && z.is_sure !== 0 + if (!sure && lastBarT != null) t1 = Math.max(t1 || 0, lastBarT) + if (sure) { + fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom') + } else { + fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'zs_pending_top', 'zs_pending_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') + var t1 = z.t1 + var sure = z.is_sure !== false && z.is_sure !== 0 + if (!sure && lastBarT != null) t1 = Math.max(t1 || 0, lastBarT) + if (sure) { + fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom') + } else { + fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'segzs_pending_top', 'segzs_pending_bottom') + } }) } @@ -233,6 +250,10 @@ { id: 'zs_bottom', type: 'line' }, { id: 'segzs_top', type: 'line' }, { id: 'segzs_bottom', type: 'line' }, + { id: 'zs_pending_top', type: 'line' }, + { id: 'zs_pending_bottom', type: 'line' }, + { id: 'segzs_pending_top', type: 'line' }, + { id: 'segzs_pending_bottom', type: 'line' }, ] BSP_SUBTYPES.forEach(function (t) { @@ -279,6 +300,22 @@ linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false, transparency: 100, visible: false, color: '#ef6c00', display: 0, }), + zs_pending_top: mergeStyle('zs_pending_top', { + linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false, + transparency: 100, visible: false, color: '#f1c40f', display: 0, + }), + zs_pending_bottom: mergeStyle('zs_pending_bottom', { + linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false, + transparency: 100, visible: false, color: '#f1c40f', display: 0, + }), + segzs_pending_top: mergeStyle('segzs_pending_top', { + linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false, + transparency: 100, visible: false, color: '#9b59b6', display: 0, + }), + segzs_pending_bottom: mergeStyle('segzs_pending_bottom', { + linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false, + transparency: 100, visible: false, color: '#9b59b6', display: 0, + }), } // BSP 样式 @@ -311,6 +348,10 @@ zs_bottom: { title: '中枢下沿', histogramBase: 0, isHidden: true }, segzs_top: { title: '段中枢上沿', histogramBase: 0, isHidden: true }, segzs_bottom: { title: '段中枢下沿', histogramBase: 0, isHidden: true }, + zs_pending_top: { title: '未完成中枢上沿', histogramBase: 0, isHidden: true }, + zs_pending_bottom: { title: '未完成中枢下沿', histogramBase: 0, isHidden: true }, + segzs_pending_top: { title: '未完成段中枢上沿', histogramBase: 0, isHidden: true }, + segzs_pending_bottom: { title: '未完成段中枢下沿', histogramBase: 0, isHidden: true }, } BSP_SUBTYPES.forEach(function (t) { @@ -358,7 +399,7 @@ name: '缠论', metainfo: { _metainfoVersion: 53, - id: 'Chan@tv-basicstudies-5', + id: 'Chan@tv-basicstudies-6', scriptIdPart: '', description: 'Chan 缠论', shortDescription: '缠论', @@ -373,12 +414,18 @@ title: '中枢', isHidden: false }, { id: 'segzs_fill', objAId: 'segzs_top', objBId: 'segzs_bottom', type: 'plot_plot', title: '段中枢', isHidden: false }, + { id: 'zs_pending_fill', objAId: 'zs_pending_top', objBId: 'zs_pending_bottom', type: 'plot_plot', + title: '未完成中枢', isHidden: false }, + { id: 'segzs_pending_fill', objAId: 'segzs_pending_top', objBId: 'segzs_pending_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 }), + zs_pending_fill: mergeFill('zs_pending_fill', { color: '#f1c40f', visible: true, transparency: 55 }), + segzs_pending_fill: mergeFill('segzs_pending_fill', { color: '#9b59b6', visible: true, transparency: 55 }), }, precision: 2, inputs: { epoch: 0 }, @@ -394,8 +441,8 @@ self._context = ctx } this.main = function (context) { - // 32 个 plot: 8 结构 + 24 BSP - var NANS = new Array(32).fill(NaN) + // 36 个 plot: 12 结构 + 24 BSP + var NANS = new Array(36).fill(NaN) // v31: sniffing pass 时 context.symbol.time 为 NaN var t = context.symbol.time if (isNaN(t)) return NANS @@ -415,6 +462,10 @@ e.zs_bottom != null ? e.zs_bottom : NaN, e.segzs_top != null ? e.segzs_top : NaN, e.segzs_bottom != null ? e.segzs_bottom : NaN, + e.zs_pending_top != null ? e.zs_pending_top : NaN, + e.zs_pending_bottom != null ? e.zs_pending_bottom : NaN, + e.segzs_pending_top != null ? e.segzs_pending_top : NaN, + e.segzs_pending_bottom != null ? e.segzs_pending_bottom : NaN, ] BSP_SUBTYPES.forEach(function (sub) { diff --git a/web/static/js/app/chart_sync.js b/web/static/js/app/chart_sync.js index 286e7b9..23c9c62 100644 --- a/web/static/js/app/chart_sync.js +++ b/web/static/js/app/chart_sync.js @@ -263,8 +263,9 @@ function updateTradingViewData() { } } - // 重新显示笔、线段和中枢等图形 - redrawFractalElements(); + // 不再调用 redrawFractalElements():它会全量 initTradingView, + // 与增量更新叠加会导致图表反复重建、内存暴涨。 + // 笔/段/中枢仍随「手动刷新 / 全量 refreshChart」重建;自动刷新走增量路径。 // 更新EMA52显示 updateEMA52Display(currentData); diff --git a/web/static/js/app/chart_tv.js b/web/static/js/app/chart_tv.js index 6ed1e1c..25284ef 100644 --- a/web/static/js/app/chart_tv.js +++ b/web/static/js/app/chart_tv.js @@ -1,33 +1,56 @@ /* chart_tv.js — split from chart.js */ + +/** 释放 Lightweight Charts 实例、DOM 与全局事件,避免自动刷新内存泄漏 */ +function disposeTradingViewCharts() { + try { + if (window._tvInitCleanups && Array.isArray(window._tvInitCleanups)) { + window._tvInitCleanups.forEach(function (fn) { try { fn(); } catch (e) {} }); + } + window._tvInitCleanups = []; + if (window._bindSyncCleanups && Array.isArray(window._bindSyncCleanups)) { + window._bindSyncCleanups.forEach(function (fn) { try { fn(); } catch (e) {} }); + } + window._bindSyncCleanups = []; + if (window._tooltipCleanups && Array.isArray(window._tooltipCleanups)) { + window._tooltipCleanups.forEach(function (fn) { try { fn(); } catch (e) {} }); + } + window._tooltipCleanups = []; + + document.querySelectorAll( + '.volume-crosshair-line, .atr-crosshair-line, .macd-crosshair-line, .chanmacd-crosshair-line' + ).forEach(function (el) { try { el.remove(); } catch (e) {} }); + + if (typeof clearEMA52Series === 'function') { + try { clearEMA52Series(); } catch (e) {} + } + + if (tvWidget) { + ['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart'].forEach(function (key) { + try { + if (tvWidget[key] && typeof tvWidget[key].remove === 'function') { + tvWidget[key].remove(); + } + } catch (e) {} + tvWidget[key] = null; + }); + if (tvWidget.state) { + tvWidget.state.isInitialized = false; + } + } + + var chartRoot = document.getElementById('tradingview_chart'); + if (chartRoot) { + chartRoot.innerHTML = ''; + } + } catch (e) { + console.warn('disposeTradingViewCharts 失败(可忽略):', e); + } +} + function initTradingView(symbol, timeframe) { try { -// 在重新初始化前,尝试释放旧图表与系列资源,避免 GPU 内存累积 -try { - if (tvWidget && tvWidget.state && tvWidget.state.isInitialized) { - // 主图 - if (tvWidget.mainChart && typeof tvWidget.mainChart.remove === 'function') { - tvWidget.mainChart.remove(); - } - // 成交量 - if (tvWidget.volumeChart && typeof tvWidget.volumeChart.remove === 'function') { - tvWidget.volumeChart.remove(); - } - // 旧 MACD(若存在) - if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') { - tvWidget.macdChart.remove(); - } - // 新 ChanMACD(若存在) - if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') { - tvWidget.chanMacdChart.remove(); - } - // ATR - if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') { - tvWidget.atrChart.remove(); - } - } -} catch (e) { - console.warn('释放旧图表资源失败(可忽略):', e); -} + // 每次重建前完整释放,防止自动刷新导致 GPU/监听器泄漏 + disposeTradingViewCharts(); console.log('初始化TradingView图表:', symbol, timeframe); // 获取当前交易对的配置 @@ -98,11 +121,7 @@ try { candles = filterTradingHours(candles, symbolConfig); console.log(`A股数据过滤: ${originalLength} -> ${candles.length} 条记录`); } - // 清除图表容器(释放旧 DOM 与 Canvas) - const chartRoot = document.getElementById('tradingview_chart'); - if (chartRoot) chartRoot.innerHTML = ''; - - // 重置图表对象 + // 重置图表对象(容器已在 disposeTradingViewCharts 清空) tvWidget = { mainChart: null, volumeChart: null, @@ -235,8 +254,7 @@ try { container.appendChild(chanMacdChartContainer); } - // 防止同步过程中的无限循环 - let syncInProgress = false; + // 防止同步过程中的无限循环(实际同步由 bindSyncEvents 负责) // 创建统一的图表选项 const createChartOptions = (showTimeScale = true, chartType = 'main') => { @@ -1052,243 +1070,8 @@ try { window.kluDivMarkersSubSub = []; } - // 实现三图联动滚动 - - // 同步图表的时间范围 - function syncCharts(sourceChart, sourceContainer) { - // 防止无限循环 - 使用更精确的检查 - if (syncInProgress) { - console.log('🔄 同步正在进行中,跳过此次同步'); - return; - } - - syncInProgress = true; - console.log('🚀 开始同步图表,来源:', - sourceChart === mainChart ? '主图' : - sourceChart === volumeChart ? '成交量图' : - sourceChart === atrChart ? 'ATR图' : - sourceChart === macdChart ? 'MACD图' : - sourceChart === chanMacdChart ? 'ChanMACD图' : '未知图表'); - - try { - if (sourceChart && sourceChart.timeScale) { - const logicalRange = sourceChart.timeScale().getVisibleLogicalRange(); - - if (logicalRange && logicalRange.from !== undefined && logicalRange.to !== undefined) { - console.log('📊 同步时间范围:', logicalRange); - - // 同步主图 - if (sourceChart !== mainChart && mainChart && mainChart.timeScale) { - try { - mainChart.timeScale().setVisibleLogicalRange(logicalRange); - console.log('✅ 主图同步完成'); - } catch (e) { - console.error('❌ 主图同步失败:', e); - } - } - - // 同步成交量图 - if (sourceChart !== volumeChart && volumeChart && volumeChart.timeScale) { - try { - volumeChart.timeScale().setVisibleLogicalRange(logicalRange); - console.log('✅ 成交量图同步完成'); - } catch (e) { - console.error('❌ 成交量图同步失败:', e); - } - } - - // 同步ATR图 - if (sourceChart !== atrChart && atrChart && atrChart.timeScale) { - try { - atrChart.timeScale().setVisibleLogicalRange(logicalRange); - console.log('✅ ATR图同步完成'); - } catch (e) { - console.error('❌ ATR图同步失败:', e); - } - } - - // 同步MACD图 - if (showMacd && macdChart && sourceChart !== macdChart && macdChart.timeScale) { - try { - macdChart.timeScale().setVisibleLogicalRange(logicalRange); - console.log('✅ MACD图同步完成'); - } catch (e) { - console.error('❌ MACD图同步失败:', e); - } - } - - // 同步ChanMACD图 - if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) { - try { - chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange); - console.log('✅ ChanMACD图同步完成'); - } catch (e) { - console.error('❌ ChanMACD图同步失败:', e); - } - } - - // 保存当前的可见范围到全局状态 - if (tvWidget && tvWidget.state) { - tvWidget.state.logicalRange = logicalRange; - } - } else { - console.warn('⚠️ 无效的逻辑范围:', logicalRange); - } - } else { - console.warn('⚠️ 无效的源图表或时间刻度'); - } - } catch (e) { - console.error('💥 同步图表出错:', e); - } - - // 立即重置同步标志,提高响应速度 - setTimeout(() => { - syncInProgress = false; - console.log('🔓 同步标志已重置'); - }, 1); - } - - // 用于跟踪所有图表的拖动状态 - let localDragStates = { - main: false, - volume: false, - atr: false, - macd: false, - chanmacd: false - }; - - // 全局鼠标抬起事件(只添加一次) - document.addEventListener('mouseup', () => { - // 重置所有拖动状态 - Object.keys(localDragStates).forEach(key => { - if (localDragStates[key]) { - console.log(`全局鼠标抬起,重置${key}图表拖动状态`); - localDragStates[key] = false; - } - }); - }); - - // 为每个图表添加事件监听 - const addChartSyncEvents = (chartContainer, chart) => { - console.log('为图表添加同步事件监听:', - chart === mainChart ? '主图' : - chart === volumeChart ? '成交量图' : - chart === atrChart ? 'ATR图' : - chart === macdChart ? 'MACD图' : - chart === chanMacdChart ? 'ChanMACD图' : '未知图表'); - - // 确定当前图表类型 - const chartType = chart === mainChart ? 'main' : - chart === volumeChart ? 'volume' : - chart === atrChart ? 'atr' : - chart === macdChart ? 'macd' : - chart === chanMacdChart ? 'chanmacd' : 'unknown'; - - // 使用LightweightCharts内置的时间范围变化事件(这是最可靠的方法) - chart.timeScale().subscribeVisibleTimeRangeChange(() => { - // 使用图表特定的同步标志防止递归 - if (!syncInProgress) { - console.log('✅ 检测到时间范围变化,触发同步:', chartType, '当前范围:', chart.timeScale().getVisibleLogicalRange()); - syncCharts(chart, chartContainer); - } else { - console.log('⏸️ 同步进行中,跳过时间范围变化事件:', chartType); - } - }); - - // 备用的DOM事件监听(用于调试和额外保障) - let isScrolling = false; - - // 鼠标按下事件 - chartContainer.addEventListener('mousedown', (e) => { - localDragStates[chartType] = true; - console.log('鼠标按下开始拖动:', chartType); - }); - - // 鼠标抬起事件 - chartContainer.addEventListener('mouseup', (e) => { - if (localDragStates[chartType]) { - localDragStates[chartType] = false; - console.log('鼠标抬起,结束拖动:', chartType); - } - }); - - // 鼠标离开事件 - chartContainer.addEventListener('mouseleave', (e) => { - if (localDragStates[chartType]) { - localDragStates[chartType] = false; - console.log('鼠标离开容器,结束拖动:', chartType); - } - }); - - // 滚轮缩放事件(保持原有逻辑) - chartContainer.addEventListener('wheel', (e) => { - if (!isScrolling) { - isScrolling = true; - console.log('滚轮缩放:', chartType); - setTimeout(() => { - if (!syncInProgress) { - syncCharts(chart, chartContainer); - } - isScrolling = false; - }, 50); - } - }); - }; - - // 添加事件监听 - addChartSyncEvents(mainChartContainer, mainChart); - addChartSyncEvents(volumeChartContainer, volumeChart); - addChartSyncEvents(atrChartContainer, atrChart); - if (showMacd && macdChart) { - addChartSyncEvents(macdChartContainer, macdChart); - } - if (showMacd && chanMacdChart) { - addChartSyncEvents(chanMacdChartContainer, chanMacdChart); - } - - // 窗口大小变化时重绘图表 - window.addEventListener('resize', () => { - // 调整主图大小 - mainChart.applyOptions({ - width: mainChartContainer.clientWidth, - height: mainChartContainer.clientHeight - }); - - // 调整成交量图大小 - volumeChart.applyOptions({ - width: volumeChartContainer.clientWidth, - height: volumeChartContainer.clientHeight - }); - - // 调整ATR图大小 - atrChart.applyOptions({ - width: atrChartContainer.clientWidth, - height: atrChartContainer.clientHeight - }); - - // 调整MACD图大小 - if (showMacd && macdChart && macdChartContainer) { - macdChart.applyOptions({ - width: macdChartContainer.clientWidth, - height: macdChartContainer.clientHeight - }); - } - - // 调整ChanMACD图大小 - if (showMacd && chanMacdChart && chanMacdChartContainer) { - chanMacdChart.applyOptions({ - width: chanMacdChartContainer.clientWidth, - height: chanMacdChartContainer.clientHeight - }); - } - - // 重新同步 - 使用主图作为同步源 - setTimeout(() => { - if (mainChart) { - syncCharts(mainChart, mainChartContainer); - } - }, 200); - }); + // 图表同步事件统一由文末 bindSyncEvents 注册(带 cleanup),此处不再重复 addEventListener, + // 否则每次自动刷新/重建都会在 document/window 上堆积监听导致内存泄漏。 // 显示笔的绘制 - 分别处理主周期、次周期和次次周期 if ($('#showMainBi').is(':checked') || $('#showElementBi').is(':checked') || $('#showSubSubBi').is(':checked')) { console.log('绘制笔 - 已启用'); diff --git a/web/static/js/app/chart_view.js b/web/static/js/app/chart_view.js index 35d8098..d546353 100644 --- a/web/static/js/app/chart_view.js +++ b/web/static/js/app/chart_view.js @@ -1,5 +1,6 @@ /* chart_view.js — split from chart.js */ -function updateChart() { +function updateChart(options) { + options = options || {}; // 只显示旋转加载图标 $('#refreshLoadingSpinner').show(); @@ -18,7 +19,7 @@ function updateChart() { const subSubTimeframe = $('#subSubTimeframe').val() || ''; // 确保时区参数有效 - console.log('更新图表使用时区:', timezone); + console.log('更新图表使用时区:', timezone, 'reason:', options.reason || (options.fromAutoRefresh ? 'auto' : 'manual')); console.log('数据源:', dataSource, '交易对/股票:', symbol); // 如果symbol为空,不发送请求 @@ -41,10 +42,15 @@ function updateChart() { if ($('#end_time').val()) { endTimeMs = new Date($('#end_time').val()).getTime(); } + + // 自动刷新:取消进行中的上一请求,避免响应堆积 + if (options.fromAutoRefresh && window._analyzeXhr && window._analyzeXhr.readyState !== 4) { + try { window._analyzeXhr.abort(); } catch (e) {} + } // 发送请求 const requestId = ++lastRequestId; // 标记本次请求 - $.ajax({ + window._analyzeXhr = $.ajax({ url: '/api/analyze', data: { symbol: symbol, @@ -75,15 +81,25 @@ function updateChart() { } currentData = data; - refreshChart(data); + refreshChart(data, { + incremental: options.incremental !== undefined + ? !!options.incremental + : !!options.fromAutoRefresh + }); }, error: function(jqXHR, textStatus, errorThrown) { // 隐藏加载图标 $('#refreshLoadingSpinner').hide(); + if (textStatus === 'abort') { + return; + } // 显示错误信息 console.error('加载数据失败:', errorThrown); - alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown)); + // 自动刷新失败不弹窗打扰 + if (!options.fromAutoRefresh) { + alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown)); + } } }); } diff --git a/web/static/js/app/datafeed.js b/web/static/js/app/datafeed.js index 2e26e2b..8a0dae7 100644 --- a/web/static/js/app/datafeed.js +++ b/web/static/js/app/datafeed.js @@ -1,20 +1,27 @@ /** * TradingView Datafeed — 对接 Data Provider 微服务 * - * 数据源: http://103.179.242.166 - * - GET /timeframes → 可用周期 - * - GET /api/candles → 历史 OHLCV - * - WS /ws → 实时 K 线推送 + * REST: https://provider.jackyu66.com + * WS: wss://jackyu66.com/ws (可通过 window.DATA_SERVICE_* 或 URL 参数覆盖) * - * 实现 IDatafeedChartApi 核心接口: - * onReady, resolveSymbol, getBars, subscribeBars, unsubscribeBars + * 实时:subscribeBars → 收到 kline 后调用 onTick(由 Charting Library 增量更新,不重置缩放) */ var ChanTVDatafeed = (function () { 'use strict' - // 默认 data_provider 地址,可通过 URL param 覆盖 - var DATA_HOST = 'http://103.179.242.166' + function getParam(name) { + try { + var m = (new RegExp('[?&]' + name + '=([^&]*)')).exec(location.search) + return m ? decodeURIComponent(m[1]) : '' + } catch (e) { + return '' + } + } + + // REST 与 WS 可分离(nginx 反代) + var DATA_HOST = (window.DATA_SERVICE_URL || getParam('data_host') || 'https://provider.jackyu66.com').replace(/\/$/, '') + var WS_URL = (window.DATA_SERVICE_WS_URL || getParam('ws_url') || 'wss://jackyu66.com/ws').replace(/\/$/, '') // ---- resolution <-> timeframe 转换 ---- var RES_TO_TF = { @@ -36,13 +43,12 @@ var ChanTVDatafeed = (function () { 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) + ws = new WebSocket(WS_URL) } catch (e) { console.warn('[TV Datafeed] WS 连接失败', e) scheduleReconnect() @@ -50,7 +56,7 @@ var ChanTVDatafeed = (function () { } ws.onopen = function () { - console.log('[TV Datafeed] WS 已连接') + console.log('[TV Datafeed] WS 已连接', WS_URL) // 重新订阅 Object.keys(wsSubs).forEach(function (guid) { var sub = wsSubs[guid] @@ -62,28 +68,35 @@ var ChanTVDatafeed = (function () { try { var msg = JSON.parse(evt.data) var bars = msg.data || msg.bars // data_provider 用 'data' 字段 + // 历史快照交给 getBars;实时只走 kline → onTick,避免冲掉缩放 + if (msg.type === 'snapshot' || msg.type === 'subscribed') return 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 + if (!latest || latest.timestamp == null) return 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 + // 允许同 timestamp 更新未收盘棒(用 < 而不是 <=) + if (sub.lastTickTime != null && latest.timestamp < sub.lastTickTime) return + var tick = { + time: latest.timestamp, + open: latest.open, + high: latest.high, + low: latest.low, + close: latest.close, + volume: latest.volume, + } try { - sub.onTick({ - time: latest.timestamp, - open: latest.open, - high: latest.high, - low: latest.low, - close: latest.close, - volume: latest.volume, - }) + sub.onTick(tick) sub.lastTickTime = latest.timestamp } catch (e) { /* ignore */ } + // 通知页面:更新缠论缓存(K 线由 TV onTick 处理,不重置缩放) + try { + if (window.ChanTvRealtime && typeof window.ChanTvRealtime.onBar === 'function') { + window.ChanTvRealtime.onBar(msg.symbol, msg.timeframe, latest) + } + } catch (e2) { /* ignore */ } } }) } diff --git a/web/static/js/app/ui.js b/web/static/js/app/ui.js index 82e0cde..e34f1fe 100644 --- a/web/static/js/app/ui.js +++ b/web/static/js/app/ui.js @@ -88,15 +88,13 @@ $(document).ready(function() { .always(function() { loadAStockSymbols(); startAStockStatusUpdater(); + // A 股:metadata 完成后再拉数(下方不再重复 updateChart) setTimeout(function() { updateChart(); }, 300); }); - } else { - setTimeout(function() { - updateChart(); - }, 500); } + // 加密货币:统一在文末单次 updateChart,避免重复请求 // 初始化交易对下拉菜单 $('#symbol').val('BTC/USDT:USDT'); @@ -245,6 +243,7 @@ $(document).ready(function() { // 自动刷新相关变量 let autoRefreshTimer = null; let nextRefreshTime = null; +let autoRefreshTick = 0; // 初始化自动刷新功能 function initAutoRefresh() { // 监听自动刷新勾选框变化 @@ -281,12 +280,18 @@ function startAutoRefresh() { updateNextRefreshTimeDisplay(); // 启动定时器 + autoRefreshTick = 0; autoRefreshTimer = setInterval(function() { // 更新结束时间为当前时间 updateEndTimeToNow(); - // 刷新图表 - updateChart(); + // 多数周期增量更新;每隔若干次全量重建以刷新笔/段/中枢(dispose 已防泄漏) + autoRefreshTick += 1; + const fullRebuild = (autoRefreshTick % 6) === 0; + updateChart({ + fromAutoRefresh: true, + incremental: !fullRebuild + }); // 更新下次刷新时间 nextRefreshTime = new Date(Date.now() + intervalMs); @@ -512,7 +517,7 @@ function updateFractalTables() { } // 刷新图表并更新表格 -function refreshChart(data) { +function refreshChart(data, options) { // 检查是否接收到数据 if (!data) { console.error('未收到数据,无法刷新图表'); @@ -522,6 +527,31 @@ function refreshChart(data) { if (data.element_timeframe) { $('#elementTimeframe').val(data.element_timeframe); } + + options = options || {}; + const preferIncremental = !!options.incremental; + const chartsReady = tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart; + + // 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts + if (preferIncremental && chartsReady) { + try { + if (tvWidget.mainChart) { + try { + window._pendingRestoreView = captureChartViewState(tvWidget.mainChart); + } catch (e) { + window._pendingRestoreView = null; + } + } + updateTradingViewData(); + updateTables(data); + if (currentData && currentData.ema52_dict) { + updateEMA52Display(currentData); + } + return; + } catch (e) { + console.warn('增量刷新失败,回退全量重建:', e); + } + } // 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window // tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上 diff --git a/web/templates/chan_tv.html b/web/templates/chan_tv.html index abf4bcc..f037cc8 100644 --- a/web/templates/chan_tv.html +++ b/web/templates/chan_tv.html @@ -119,7 +119,7 @@
- + + 指标会自动存到本机,刷新后恢复 初始化... @@ -147,26 +148,28 @@ + - - - + + + diff --git a/web/templates/index.html b/web/templates/index.html index 87e48e7..cbe16ef 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1264,11 +1264,11 @@ - - - + + + - +