fix: 修复主站自动刷新内存泄漏,并完善 chan_tv 图表体验

主站重建前完整 dispose、去掉重复 sync 监听,自动刷新默认增量更新;顺带消除首屏重复 analyze、复用 ChanMACD,以及全版 TV 指标/未完成中枢/布局本地缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-06 16:09:48 +08:00
co-authored by Cursor
parent 6b0f3b5837
commit 9f1e7361b6
14 changed files with 457 additions and 669 deletions
+3 -1
View File
@@ -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
+4 -1
View File
@@ -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)
# 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.cal_macd_state()
self.klu_list = self.chanmacd.klu_list
def get_current_klc(self):
+6 -1
View File
@@ -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/<path:filename>')
def serve_charting_library(filename):
+5
View File
@@ -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
+3 -1
View File
@@ -618,13 +618,15 @@ 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 = 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,
+52 -31
View File
@@ -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,
+56 -5
View File
@@ -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) {
+3 -2
View File
@@ -263,8 +263,9 @@ function updateTradingViewData() {
}
}
// 重新显示笔、线段和中枢等图形
redrawFractalElements();
// 不再调用 redrawFractalElements():它会全量 initTradingView
// 与增量更新叠加会导致图表反复重建、内存暴涨。
// 笔/段/中枢仍随「手动刷新 / 全量 refreshChart」重建;自动刷新走增量路径。
// 更新EMA52显示
updateEMA52Display(currentData);
+54 -271
View File
@@ -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('绘制笔 - 已启用');
+20 -4
View File
@@ -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为空,不发送请求
@@ -42,9 +43,14 @@ function updateChart() {
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,16 +81,26 @@ 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);
// 自动刷新失败不弹窗打扰
if (!options.fromAutoRefresh) {
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
}
}
});
}
function captureChartViewState(chart) {
+32 -19
View File
@@ -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
try {
sub.onTick({
// 允许同 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(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 */ }
}
})
}
+37 -7
View File
@@ -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('未收到数据,无法刷新图表');
@@ -523,6 +528,31 @@ function refreshChart(data) {
$('#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 上
if (tvWidget && tvWidget.mainChart) {
+161 -305
View File
@@ -119,7 +119,7 @@
<!-- 控制栏 -->
<div class="toolbar">
<span class="logo"><span></span></span>
<span class="logo"><span></span> <small style="font-weight:500;color:#888;font-size:12px;">全版</small></span>
<input id="symbol-input" type="text" value="BTC/USDT:USDT" title="交易对">
<select id="tf-select" title="周期">
<option value="1m">1m</option>
@@ -135,6 +135,7 @@
</select>
<button id="btn-reload" class="btn btn-reload" title="刷新数据">↻ 刷新</button>
<button id="btn-theme" class="btn btn-theme" title="切换主题">🌙</button>
<span style="font-size:12px;color:#666;white-space:nowrap;">指标会自动存到本机,刷新后恢复</span>
<span class="status" id="status-bar">
<span class="dot yellow"></span> 初始化...
</span>
@@ -147,26 +148,28 @@
<!-- TV charting library -->
<script src="/charting_library/charting_library.js"></script>
<!-- 自定义模块 -->
<script>
window.DATA_SERVICE_URL = "{{ data_service_url }}";
window.DATA_SERVICE_WS_URL = "{{ data_service_ws_url }}";
</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 src="/static/js/app/chan_engine.js?v=5"></script>
<script src="/static/js/app/datafeed.js?v=8"></script>
<script src="/static/js/app/chan_indicator.js?v=11"></script>
<script>
(function () {
'use strict'
// ---- 配置 ----
var DATA_HOST = 'http://103.179.242.166'
// WebSocket URL
var WS_URL = DATA_HOST.replace(/^http/, 'ws') + '/ws'
// ---- 读取 URL params ----
function getParam(name, def) {
var m = (new RegExp('[?&]' + name + '=([^&]*)')).exec(location.search)
return m ? decodeURIComponent(m[1]) : def
}
// ---- 配置(REST / WS 可分离)----
var DATA_HOST = (window.DATA_SERVICE_URL || getParam('data_host', 'https://provider.jackyu66.com')).replace(/\/$/, '')
var defaultSymbol = getParam('symbol', 'BTC/USDT:USDT')
var defaultTf = getParam('tf', '5m')
@@ -191,7 +194,6 @@
var lastChanKey = ''
var fetchPromise = null // 防止并发请求
var computePending = false // 标记是否有待处理的计算
var computeTimeout = null // 防抖,避免 WS 洪水
// ---- 工具:resolution ↔ timeframe ----
function tfToRes(tf) {
@@ -378,6 +380,36 @@
setStatus('ok')
}
// ---- 本地缓存图表布局(含已开指标)----
var CHART_STATE_KEY = 'chan_tv_chart_state_v1'
function loadSavedChartState() {
try {
var raw = localStorage.getItem(CHART_STATE_KEY)
if (!raw) return null
return JSON.parse(raw)
} catch (e) {
console.warn('[布局] 读取本地缓存失败', e)
return null
}
}
function persistChartState() {
if (!widget || typeof widget.save !== 'function') return
try {
widget.save(function (state) {
try {
localStorage.setItem(CHART_STATE_KEY, JSON.stringify(state))
console.log('[布局] 已缓存到本地(含指标)')
} catch (e) {
console.warn('[布局] 写入 localStorage 失败', e)
}
})
} catch (e) {
console.warn('[布局] save 失败', e)
}
}
// ---- 初始化 TV Widget ----
function initTvWidget() {
var symbol = symbolInput.value.trim()
@@ -394,15 +426,15 @@
computeAndRefreshChan(true)
}
})
// WS 重新订阅新周期
if (window._resubscribeChanWS) window._resubscribeChanWS()
// WS 重新订阅由 datafeed.subscribeBars 自动处理
} catch (e) {
console.warn('setSymbol failed', e)
}
return
}
widget = new TradingView.widget({
var savedState = loadSavedChartState()
var widgetOpts = {
container: chartContainer,
library_path: '/charting_library/',
datafeed: ChanTVDatafeed,
@@ -413,31 +445,65 @@
theme: getTheme(),
timezone: 'Asia/Shanghai',
locale: 'zh',
toolbar_bg: '#f8f9fa',
toolbar_bg: getTheme() === 'dark' ? '#131722' : '#f8f9fa',
client_id: 'chan-core',
user_id: 'local',
auto_save_delay: 3, // 秒;变更后自动触发 onAutoSaveNeeded
// 仅注入缠论;其余全部走 TradingView 内置指标库(不设 studies_access = 不限制)
custom_indicators_getter: function () {
return Promise.resolve([makeChanIndicator()])
},
// 币安式:顶部可点「指标」添加全部内置指标;左侧绘图工具栏
// 注意:不要开 study_templates(无 charts_storage_url 会 404 打断工具栏)
disabled_features: [
'header_compare',
'header_saveload',
'study_templates',
'create_volume_indicator_by_default',
'save_chart_properties_to_local_storage',
'volume_force_overlay', // 关:成交量独立副图,不叠主图
],
enabled_features: [
'hide_left_toolbar_by_default',
'header_widget',
'header_indicators',
'header_fullscreen_button',
'header_chart_type',
'header_resolutions',
'header_settings',
'header_undo_redo',
'header_screenshot',
'left_toolbar',
'control_bar',
'timeframes_toolbar',
'edit_buttons_in_legend',
'context_menus',
'legend_context_menu',
'pane_context_menu',
'scales_context_menu',
'show_object_tree',
'items_favoriting',
'insert_indicator_dialog_shortcut',
'caption_buttons_text_if_possible',
'create_volume_indicator_by_default',
// 不用 volume_force_overlay:成交量单独副图,与主图分开
'display_legend_on_all_charts',
'use_localstorage_for_settings',
],
favorites: {
intervals: ['1', '5', '15', '30', '60', '240', 'D'],
chartTypes: ['Candles'],
intervals: ['1', '3', '5', '15', '30', '60', '240', 'D'],
chartTypes: ['Candles', 'Line', 'Area'],
indicators: [
'Moving Average',
'EMA Cross',
'MACD',
'Relative Strength Index',
'Bollinger Bands',
'Volume',
'Stochastic',
'Average True Range',
'缠论',
],
},
studies_overrides: {
'macd.macd.display': 3,
'macd.signal.display': 3,
'macd.histogram.display': 3,
'volume.volume.color.0': 'rgba(8, 153, 129, 0.4)',
'volume.volume.color.1': 'rgba(239, 68, 68, 0.4)',
},
overrides: {
'paneProperties.background': getTheme() === 'light' ? '#ffffff' : '#131722',
@@ -449,28 +515,54 @@
'mainSeriesProperties.candleStyle.wickUpColor': '#ef4444',
'mainSeriesProperties.candleStyle.wickDownColor': '#089981',
},
})
}
// 有本地缓存则恢复布局(含已开指标);会覆盖构造时的 symbol/interval
if (savedState) {
widgetOpts.saved_data = savedState
console.log('[布局] 从本地恢复上次指标/布局')
}
widget = new TradingView.widget(widgetOpts)
widget.onChartReady(function () {
chart = widget.activeChart()
window._chanChart = chart // for debugging
if (!chart) return
// 创建 MACD
// 缓存恢复后,若工具栏交易对/周期与缓存不一致,切回工具栏选择(保留指标)
try {
chart.createStudy('MACD', false, false)
var curSym = chart.symbol()
var curRes = chart.resolution()
var wantRes = tfToRes(tf)
if (curSym !== symbol || String(curRes) !== String(wantRes)) {
widget.setSymbol(symbol, wantRes, function () {
chart = widget.activeChart()
})
}
} catch (e) {
console.warn('createStudy MACD failed', e)
console.warn('同步工具栏 symbol/tf 失败', e)
}
// 调整 MACD 副图大小
// 自动保存:加指标/改设置后写入 localStorage
try {
var panes = chart.getPanes()
if (panes.length >= 2) {
panes[panes.length - 1].setHeight(150)
}
widget.subscribe('onAutoSaveNeeded', function () {
persistChartState()
})
} catch (e) {
console.warn('resize MACD pane failed', e)
console.warn('subscribe onAutoSaveNeeded failed', e)
}
// 兜底:指标增删时也存一份
try {
widget.subscribe('study', function () {
setTimeout(persistChartState, 500)
})
} catch (e) { /* 部分版本无此事件 */ }
// 确保缠论指标挂上(自定义)
try {
ensureAndPokeChanStudy(chart)
} catch (e) {
console.warn('ensure Chan study failed', e)
}
// 监听数据加载完 → 刷新缠论
@@ -479,7 +571,7 @@
// 监听可视范围变化 → 刷新缠论
attachVisibleRangeListener(chart)
// 监听样式变化 → 持久化
// 监听样式变化 → 持久化缠论样式 + 整图布局
try {
widget.subscribe('study_properties_changed', function (entityId) {
var studies = chart.getAllStudies ? chart.getAllStudies() : []
@@ -491,6 +583,7 @@
var sv = api ? api.getStyleValues() : null
if (sv) saveChanStyles(sv)
}
persistChartState()
})
} catch (e) {
console.warn('subscribe study_properties_changed failed', e)
@@ -570,289 +663,52 @@
setStatus('loading');
initTvWidget();
// ---- WebSocket 实时 K 线 → 增量缠论更新 ----
(function () {
var ws = null
var reconnectTimer = null
var pendingBars = [] // 收集待处理的新 bar
var lastWsMsgTime = 0 // 上次收到消息的时间(心跳检测)
var heartbeatTimer = null // 心跳超时检测
var backfillTimer = null // 回补延迟
// ---- 实时:只用 TV datafeed 的 onTick 更新 K(不重置缩放)----
// 缠论缓存由 datafeed 回调同步;新棒出现(收盘)再重算缠论
;(function () {
var chanDebounce = null
var lastTs = null
function getTfMs(tf) {
var map = { '1m':60000, '3m':180000, '5m':300000, '15m':900000, '30m':1800000,
'1h':3600000, '2h':7200000, '4h':14400000, '6h':21600000, '8h':28800000,
'12h':43200000, '1d':86400000, '3d':259200000, '1w':604800000, '1M':2592000000 }
return map[tf] || 300000
}
function startHeartbeat(tf) {
stopHeartbeat()
// 期望每根 bar 至少收到一次数据,超时设为 3 倍周期 + 10 秒
var interval = Math.min(getTfMs(tf) * 3 + 10000, 60000) // 最长 60s
heartbeatTimer = setTimeout(function () {
console.warn('[WS] 心跳超时,重连...')
if (ws) { try { ws.close() } catch (e) { /* ignore */ } }
reconnectWS()
}, interval)
}
function stopHeartbeat() {
if (heartbeatTimer) clearTimeout(heartbeatTimer)
heartbeatTimer = null
}
function touchHeartbeat() {
lastWsMsgTime = Date.now()
var tf
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
startHeartbeat(tf)
}
function wsConnect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
try { ws = new WebSocket(WS_URL) }
catch (e) { return }
ws.onopen = function () {
console.log('[WS] 已连接')
setStatus('ok')
var sym, tf
try { sym = chart.symbol() } catch (e) { sym = symbolInput.value.trim() }
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
var subMsg = JSON.stringify({ action: 'subscribe', symbol: sym, timeframe: tf })
console.log('[WS] 发送订阅', subMsg)
ws.send(subMsg)
touchHeartbeat()
}
ws.onmessage = function (evt) {
touchHeartbeat()
try {
var msg = JSON.parse(evt.data)
var bars = msg.data || msg.bars
if (msg.type === 'subscribed') {
console.log('[WS] 已订阅', msg.symbol, msg.timeframe)
return
}
if (msg.type === 'snapshot' && bars) {
console.log('[WS] 收到快照', bars.length, '根 bar')
// 仅在 REST 数据尚未到达且没有进行中的请求时才用快照初始化
if (cachedOhlcvBars.length === 0 && !fetchPromise) {
for (var i = 0; i < bars.length; i++) {
var b = bars[i]
cachedOhlcvBars.push({
timestamp: b.timestamp, datetime: b.datetime || new Date(b.timestamp).toISOString(),
open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume || 0,
})
}
var dpS, tF
try { dpS = chart.symbol() } catch (e) { dpS = symbolInput.value.trim() }
try { tF = resToTf(chart.resolution()) } catch (e) { tF = tfSelect.value }
doChanCompute(cachedOhlcvBars, dpS, tF)
}
// 回补缺失数据:检查快照与缓存之间的缺口
scheduleBackfill(bars)
return
}
if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) {
window.ChanTvRealtime = {
onBar: function (symbol, tf, bar) {
if (!bar || bar.timestamp == null) return
var curSym, curTf
try { curSym = chart.symbol() } catch (e) { curSym = symbolInput.value.trim() }
try { curTf = resToTf(chart.resolution()) } catch (e) { curTf = tfSelect.value }
if (msg.symbol !== curSym || msg.timeframe !== curTf) return
try { curSym = chart && chart.symbol() } catch (e) { curSym = symbolInput.value.trim() }
try { curTf = chart && resToTf(chart.resolution()) } catch (e) { curTf = tfSelect.value }
if (symbol !== curSym || tf !== curTf) return
for (var i = 0; i < bars.length; i++) {
var bar = bars[i]
var exists = false
for (var j = cachedOhlcvBars.length - 1; j >= Math.max(0, cachedOhlcvBars.length - 100); j--) {
if (cachedOhlcvBars[j].timestamp === bar.timestamp) { exists = true; break }
}
if (exists) continue
// 也检查 pendingBars 去重
var inPending = false
for (var k = 0; k < pendingBars.length; k++) {
if (pendingBars[k].timestamp === bar.timestamp) { inPending = true; break }
}
if (inPending) continue
pendingBars.push({
timestamp: bar.timestamp, datetime: bar.datetime || new Date(bar.timestamp).toISOString(),
open: bar.open, high: bar.high, low: bar.low, close: bar.close, volume: bar.volume || 0,
})
}
if (computeTimeout) clearTimeout(computeTimeout)
computeTimeout = setTimeout(flushPendingBars, 100)
}
} catch (e) { /* ignore */ }
}
ws.onclose = function () {
ws = null
// 断线前立即处理待处理数据
if (pendingBars.length > 0) {
flushPendingBars()
}
scheduleReconnect()
}
ws.onerror = function () { /* onclose fires next */ }
}
function scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer)
// 自适应重连延迟:短周期用短延迟
var tf, delay = 3000
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
if (tf === '1m' || tf === '3m') delay = 1000
else if (tf === '5m' || tf === '15m') delay = 2000
reconnectTimer = setTimeout(reconnectWS, delay)
}
function reconnectWS() {
reconnectTimer = null
stopHeartbeat()
if (ws) { try { ws.close() } catch (e) { /* ignore */ } ws = null }
wsConnect()
}
// ---- 回补缺失数据 ----
function scheduleBackfill(snapshotBars) {
if (backfillTimer) clearTimeout(backfillTimer)
backfillTimer = setTimeout(function () { backfillGaps(snapshotBars) }, 500)
}
function backfillGaps(snapshotBars) {
if (cachedOhlcvBars.length === 0) return
// 按时间排序缓存
cachedOhlcvBars.sort(function (a, b) { return a.timestamp - b.timestamp })
var lastTs = cachedOhlcvBars[cachedOhlcvBars.length - 1].timestamp
var tf
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
var periodMs = getTfMs(tf)
// 检查最后一根 bar 到当前时间的缺口
var now = Date.now()
var expectedBars = Math.floor((now - lastTs) / periodMs) - 1
if (expectedBars <= 1) return // 缺口 <= 1 根无需回补
console.log('[WS] 检测到数据缺口:', expectedBars, '根 bar, 回补中...')
// 从快照中提取缺失的 bar
if (snapshotBars && snapshotBars.length > 0) {
var filled = 0
for (var i = 0; i < snapshotBars.length; i++) {
var b = snapshotBars[i]
if (b.timestamp <= lastTs) continue
// 去重
var dup = false
for (var j = cachedOhlcvBars.length - 1; j >= Math.max(0, cachedOhlcvBars.length - 200); j--) {
if (cachedOhlcvBars[j].timestamp === b.timestamp) { dup = true; break }
}
if (dup) continue
var closed = false
if (cachedOhlcvBars && cachedOhlcvBars.length) {
var last = cachedOhlcvBars[cachedOhlcvBars.length - 1]
if (bar.timestamp === last.timestamp) {
last.open = bar.open; last.high = bar.high; last.low = bar.low
last.close = bar.close; last.volume = bar.volume || 0
} else if (bar.timestamp > last.timestamp) {
cachedOhlcvBars.push({
timestamp: b.timestamp, datetime: b.datetime || new Date(b.timestamp).toISOString(),
open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume || 0,
timestamp: bar.timestamp,
datetime: bar.datetime || new Date(bar.timestamp).toISOString(),
open: bar.open, high: bar.high, low: bar.low, close: bar.close,
volume: bar.volume || 0,
})
filled++
if (cachedOhlcvBars.length > 5000) {
cachedOhlcvBars = cachedOhlcvBars.slice(cachedOhlcvBars.length - 5000)
}
if (filled > 0) {
console.log('[WS] 从快照回补', filled, '根 bar')
closed = true
}
}
// 如果快照不够,从 REST API 拉取
if (expectedBars > 10) {
console.log('[WS] 缺口较大,从 REST 回补...')
var dpSymbol
try { dpSymbol = chart.symbol() } catch (e) { dpSymbol = symbolInput.value.trim() }
var start = lastTs + periodMs
var url = DATA_HOST + '/api/candles?symbol=' + encodeURIComponent(dpSymbol) +
'&tf=' + encodeURIComponent(tf) + '&start=' + start + '&end=' + now + '&limit=500'
fetch(url).then(function (r) { return r.json() }).then(function (data) {
if (!Array.isArray(data)) return
var added = 0
for (var i = 0; i < data.length; i++) {
var d = data[i]
if (d.timestamp <= lastTs) continue
var dup = false
for (var j = cachedOhlcvBars.length - 1; j >= Math.max(0, cachedOhlcvBars.length - 200); j--) {
if (cachedOhlcvBars[j].timestamp === d.timestamp) { dup = true; break }
// 仅在上一根走完时重算缠论(K/MACD 已由 TV 自带更新)
if (closed || (lastTs != null && bar.timestamp > lastTs)) {
if (chanDebounce) clearTimeout(chanDebounce)
chanDebounce = setTimeout(function () {
chanDebounce = null
if (cachedOhlcvBars && cachedOhlcvBars.length) {
doChanCompute(cachedOhlcvBars, curSym, curTf)
}
if (dup) continue
cachedOhlcvBars.push({
timestamp: d.timestamp, datetime: d.datetime || new Date(d.timestamp).toISOString(),
open: d.open, high: d.high, low: d.low, close: d.close, volume: d.volume || 0,
})
added++
}, 400)
}
if (added > 0) {
cachedOhlcvBars.sort(function (a, b) { return a.timestamp - b.timestamp })
console.log('[WS] REST 回补', added, '根 bar')
doChanCompute(cachedOhlcvBars, dpSymbol, tf)
lastTs = bar.timestamp
}
}).catch(function (err) { console.warn('[WS] REST 回补失败', err.message) })
} else if (expectedBars > 1) {
// 小缺口直接触发重算
cachedOhlcvBars.sort(function (a, b) { return a.timestamp - b.timestamp })
var s, t
try { s = chart.symbol() } catch (e) { s = symbolInput.value.trim() }
try { t = resToTf(chart.resolution()) } catch (e) { t = tfSelect.value }
doChanCompute(cachedOhlcvBars, s, t)
}
}
function flushPendingBars() {
computeTimeout = null
if (pendingBars.length === 0) return
if (cachedOhlcvBars.length === 0) {
pendingBars = []
return
}
console.log('[WS] 增量更新', pendingBars.length, '根新 bar')
for (var i = 0; i < pendingBars.length; i++) {
cachedOhlcvBars.push(pendingBars[i])
}
pendingBars = []
// 去重并排序
var seen = {}
var deduped = []
for (var i = 0; i < cachedOhlcvBars.length; i++) {
var ts = cachedOhlcvBars[i].timestamp
if (!seen[ts]) { seen[ts] = true; deduped.push(cachedOhlcvBars[i]) }
}
deduped.sort(function (a, b) { return a.timestamp - b.timestamp })
cachedOhlcvBars = deduped
// 限制缓存大小(保留最近数据)
var maxBars = 5000
if (cachedOhlcvBars.length > maxBars) {
cachedOhlcvBars = cachedOhlcvBars.slice(cachedOhlcvBars.length - maxBars)
}
var dpSymbol, tf
try { dpSymbol = chart.symbol() } catch (e) { dpSymbol = null }
try { tf = resToTf(chart.resolution()) } catch (e) { tf = null }
if (!dpSymbol || !tf) return
doChanCompute(cachedOhlcvBars, dpSymbol, tf)
}
// 延迟连接
setTimeout(wsConnect, 2000)
// 暴露给 initTvWidget
window._resubscribeChanWS = function () {
stopHeartbeat()
if (ws) { try { ws.close() } catch (e) { /* ignore */ } ws = null }
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(reconnectWS, 500)
}
})()
})()
+4 -4
View File
@@ -1264,11 +1264,11 @@
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260806a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260806a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260806a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260806a"></script>
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script>