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
+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('绘制笔 - 已启用');
+21 -5
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为空,不发送请求
@@ -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));
}
}
});
}
+37 -24
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
// 允许同 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 */ }
}
})
}
+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('未收到数据,无法刷新图表');
@@ -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 上