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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-25 22:57:43 +08:00
co-authored by Cursor
parent 1e60ab3bfa
commit 8ee11317d3
104 changed files with 21452 additions and 4988 deletions
+157 -33
View File
@@ -1,5 +1,7 @@
/* chart_sync.js — split from chart.js */
function updateTradingViewData() {
function updateTradingViewData(options) {
options = options || {};
const tailOnly = !!options.tailOnly;
try {
console.log('增量更新图表数据');
@@ -9,11 +11,34 @@ function updateTradingViewData() {
return;
}
// 保存当前的可视范围
// 优先用请求前冻结的视窗;否则现场拍(自动刷新短间隔 delta≈0,两种都稳)
const frozen = window._preserveViewOnRefresh;
const oldBarCount = window._preserveViewBarCount || 0;
let savedScrollPosition = null;
let savedVisibleRange = null;
let savedLogicalRange = null;
if (tvWidget.mainChart) {
tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange();
tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
const ts = tvWidget.mainChart.timeScale();
if (frozen) {
savedVisibleRange = frozen.visibleRange;
savedLogicalRange = frozen.logicalRange;
savedScrollPosition = (typeof frozen.scrollPosition === 'number') ? frozen.scrollPosition : null;
} else {
try { savedVisibleRange = ts.getVisibleRange(); } catch (e) {}
try { savedLogicalRange = ts.getVisibleLogicalRange(); } catch (e) {}
try {
savedScrollPosition = ts.scrollPosition ? ts.scrollPosition() : null;
} catch (e) {}
}
if (tvWidget.state) {
tvWidget.state.visibleRange = savedVisibleRange;
tvWidget.state.logicalRange = savedLogicalRange;
}
}
window._preserveViewOnRefresh = null;
window._preserveViewBarCount = 0;
// setData 会触发 timeRange 回调;期间禁止 sync 写回 state(否则会把已跳回左侧的视窗当成「要恢复的目标」)
window._preserveViewDuringUpdate = true;
// 检查是否显示原始K线
const showOriginalKline = $('#showOriginalKline').is(':checked');
@@ -71,11 +96,32 @@ function updateTradingViewData() {
};
});
}
// LWC 不允许 null/NaN;时间用整秒,避免 Line 渲染抛 Value is null
candles = (candles || []).filter(function (c) {
return c && c.time != null &&
isFinite(Number(c.open)) && isFinite(Number(c.high)) &&
isFinite(Number(c.low)) && isFinite(Number(c.close));
}).map(function (c) {
return {
time: Math.floor(Number(c.time)),
open: Number(c.open),
high: Number(c.high),
low: Number(c.low),
close: Number(c.close)
};
});
const newBarCount = candles.length;
const barDelta = (oldBarCount > 0 && newBarCount > 0) ? (newBarCount - oldBarCount) : 0;
const firstBarTime = newBarCount > 0 ? candles[0].time : null;
const lastBarTime = newBarCount > 0 ? candles[newBarCount - 1].time : null;
const clampedVisibleRange = clampVisibleRangeToBarTimes(savedVisibleRange, firstBarTime, lastBarTime);
// 更新主系列数据(根据klineType)
const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
if (klineType === 'candlestick' && tvWidget.series.candleSeries) {
tvWidget.series.candleSeries.setData(candles);
applySeriesDataTail(tvWidget.series.candleSeries, candles, tailOnly);
} else if (klineType === 'renko' && tvWidget.series.renkoSeries) {
const bricks = buildRenkoFromCandles(candles);
tvWidget.series.renkoSeries.setData(bricks);
@@ -83,26 +129,30 @@ function updateTradingViewData() {
const hk = buildHeikinFromCandles(candles);
tvWidget.series.heikinSeries.setData(hk);
} else if (klineType === 'bar' && tvWidget.series.barSeries) {
tvWidget.series.barSeries.setData(candles);
applySeriesDataTail(tvWidget.series.barSeries, candles, tailOnly);
} else if (klineType === 'line' && tvWidget.series.lineSeries) {
const lineData = candles.map(c => ({ time: c.time, value: c.close }));
tvWidget.series.lineSeries.setData(lineData);
applySeriesDataTail(tvWidget.series.lineSeries, lineData, tailOnly);
} else if (klineType === 'area' && tvWidget.series.areaSeries) {
const areaData = candles.map(c => ({ time: c.time, value: c.close }));
tvWidget.series.areaSeries.setData(areaData);
applySeriesDataTail(tvWidget.series.areaSeries, areaData, tailOnly);
} else if (klineType === 'baseline' && tvWidget.series.baselineSeries) {
const baseData = candles.map(c => ({ time: c.time, value: c.close }));
tvWidget.series.baselineSeries.setData(baseData);
applySeriesDataTail(tvWidget.series.baselineSeries, baseData, tailOnly);
} else if (klineType === 'klc' && tvWidget.series.klcSeries) {
const klcCandles = buildKLCFromAnalysis(currentData);
tvWidget.series.klcSeries.setData(klcCandles);
if (tailOnly) {
applySeriesDataTail(tvWidget.series.klcSeries, klcCandles, true);
} else {
tvWidget.series.klcSeries.setData(klcCandles);
}
}
// 更新均线数据
addMovingAveragesToChart(candles);
// 更新布林带数据
addBollingerBandsToChart(candles);
// 尾部刷新不重算均线/布林带(removeSeries 会触发视窗跳动)
if (!tailOnly) {
addMovingAveragesToChart(candles);
addBollingerBandsToChart(candles);
}
// 更新成交量数据
let volumes = [];
@@ -136,9 +186,11 @@ function updateTradingViewData() {
}
if (tvWidget.series.volumeSeries) {
tvWidget.series.volumeSeries.setData(volumes);
applySeriesDataTail(tvWidget.series.volumeSeries, volumes, tailOnly);
}
// 尾部刷新不重拉 ATR/MACD(setData 会触发视窗跳到最右)
if (!tailOnly) {
// 更新ATR数据
if (tvWidget.series.atrLineSeries) {
const atrData = [];
@@ -262,6 +314,7 @@ function updateTradingViewData() {
console.warn('更新ChanMACD标注失败:', e);
}
}
}
// 不再调用 redrawFractalElements():它会全量 initTradingView
// 与增量更新叠加会导致图表反复重建、内存暴涨。
@@ -270,27 +323,98 @@ function updateTradingViewData() {
// 更新EMA52显示
updateEMA52Display(currentData);
// 恢复之前的可视范围 - 优先使用visibleRange以确保时间轴对齐
// 与自动刷新一致:增量更新绝不碰 barSpacing(缩放本来就留在图表实例上)。
// 一写 barSpacing,LWC 会按右边缘重锚 → 放大往右、缩小往左。
// 这里只在 setData 之后把位置扳回刷新前的 logical / time 窗口。
if (tvWidget.mainChart) {
if (tvWidget.state.visibleRange) {
console.log('🔄 恢复可见范围:', tvWidget.state.visibleRange);
tvWidget.mainChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
} else if (tvWidget.state.logicalRange) {
console.log('🔄 恢复逻辑范围:', tvWidget.state.logicalRange);
tvWidget.mainChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
}
const charts = [
tvWidget.mainChart,
tvWidget.volumeChart,
tvWidget.atrChart,
tvWidget.macdChart,
tvWidget.chanMacdChart
].filter(Boolean);
const vr = clampedVisibleRange || savedVisibleRange;
const lr = savedLogicalRange;
const savedScroll = savedScrollPosition;
const mainChartRef = tvWidget.mainChart;
const applyPosition = function (tag) {
let ok = false;
// scrollToPosition 最稳;setVisibleRange 的 to 常含右侧空白会锚到最右
if (typeof savedScroll === 'number' && mainChartRef) {
try {
const pos = savedScroll + (barDelta || 0);
mainChartRef.timeScale().scrollToPosition(pos, false);
const lrNow = mainChartRef.timeScale().getVisibleLogicalRange();
if (lrNow) {
charts.forEach(c => {
try { c.timeScale().setVisibleLogicalRange(lrNow); } catch (e) {}
});
ok = true;
console.log('🔄 恢复位置 scroll' + (tag || '') + ':', pos);
}
} catch (e) {}
}
if (!ok && lr && lr.from !== undefined && lr.to !== undefined && newBarCount > 0) {
const span = Math.max(1, lr.to - lr.from);
let to = lr.to;
let from = lr.from;
const maxTo = newBarCount - 1 + 8;
if (to > maxTo) {
to = maxTo;
from = to - span;
}
if (from < -8) {
from = -8;
to = from + span;
}
const clamped = { from: from, to: to };
charts.forEach(c => {
try {
c.timeScale().setVisibleLogicalRange(clamped);
ok = true;
} catch (e) {}
});
if (ok) console.log('🔄 恢复位置 logical' + (tag || '') + ':', clamped);
}
if (!ok && vr && vr.from !== undefined && vr.to !== undefined) {
charts.forEach(c => {
try {
c.timeScale().setVisibleRange(vr);
ok = true;
} catch (e) {}
});
if (ok) console.log('🔄 恢复位置 time' + (tag || '') + ':', vr);
}
};
const finishPreserve = function () {
window._preserveViewDuringUpdate = false;
if (tvWidget.mainChart && tvWidget.state) {
try {
const ts = tvWidget.mainChart.timeScale();
tvWidget.state.logicalRange = ts.getVisibleLogicalRange();
tvWidget.state.visibleRange = ts.getVisibleRange();
} catch (e) {}
}
};
applyPosition('');
setTimeout(function () { applyPosition('@0'); }, 0);
setTimeout(function () { applyPosition('@50'); }, 50);
setTimeout(function () {
applyPosition('@150');
finishPreserve();
}, 150);
} else {
window._preserveViewDuringUpdate = false;
}
console.log('增量更新图表完成');
} catch (e) {
window._preserveViewDuringUpdate = false;
console.error('增量更新图表错误,回退到完全重绘:', e);
// 出错时回退到完全重绘
initTradingView($('#symbol').val(), $('#timeframe').val());
@@ -316,7 +440,7 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
// 同步图表的时间范围
function syncCharts(sourceChart, sourceContainer) {
if (syncInProgress) return;
if (syncInProgress || window._preserveViewDuringUpdate) return;
syncInProgress = true;