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:
@@ -1,6 +1,9 @@
|
||||
/* chart_format.js — split from chart.js */
|
||||
/* chart.js */
|
||||
function updateChartDisplay() {
|
||||
if (typeof renderWyckoffCycleSummary === 'function') {
|
||||
renderWyckoffCycleSummary();
|
||||
}
|
||||
if (currentData) {
|
||||
// 检测K线周期是否切换
|
||||
const curPeriod = $('#subSubPeriodKline').is(':checked') ? 'subsub' :
|
||||
|
||||
+157
-33
@@ -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;
|
||||
|
||||
|
||||
+13
-4603
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
/* chart_tv_finalize.js — time sync / bindSync / view restore / tooltip */
|
||||
|
||||
function chartTvFinalize(ctx) {
|
||||
var symbol = ctx.symbol;
|
||||
var timeframe = ctx.timeframe;
|
||||
var symbolConfig = ctx.symbolConfig;
|
||||
var useSubSubPeriod = ctx.useSubSubPeriod;
|
||||
var useElementPeriod = ctx.useElementPeriod;
|
||||
var klinePeriodLabel = ctx.klinePeriodLabel;
|
||||
var candles = ctx.candles;
|
||||
var klineDataSource = ctx.klineDataSource;
|
||||
var container = ctx.container;
|
||||
var showMacd = ctx.showMacd;
|
||||
var showOriginalKline = ctx.showOriginalKline;
|
||||
var mainChartContainer = ctx.mainChartContainer;
|
||||
var volumeChartContainer = ctx.volumeChartContainer;
|
||||
var atrChartContainer = ctx.atrChartContainer;
|
||||
var macdChartContainer = ctx.macdChartContainer;
|
||||
var chanMacdChartContainer = ctx.chanMacdChartContainer;
|
||||
var mainChart = ctx.mainChart;
|
||||
var volumeChart = ctx.volumeChart;
|
||||
var atrChart = ctx.atrChart;
|
||||
var macdChart = ctx.macdChart;
|
||||
var chanMacdChart = ctx.chanMacdChart;
|
||||
var createChartOptions = ctx.createChartOptions;
|
||||
// 同步所有图表的时间轴配置
|
||||
const hasPendingRestoreView = !!window._pendingRestoreView;
|
||||
const pendingView = window._pendingRestoreView;
|
||||
const syncTimeScaleSettings = () => {
|
||||
const baseOptions = {
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
borderColor: '#ddd',
|
||||
lockVisibleTimeRangeOnResize: true,
|
||||
// 关键:确保所有图表边缘行为完全一致
|
||||
fixLeftEdge: false,
|
||||
fixRightEdge: false,
|
||||
// 确保时间刻度行为一致
|
||||
ticksVisible: true,
|
||||
minimumHeight: 0,
|
||||
};
|
||||
// 有待恢复视图时不要先写 barSpacing/rightOffset(会钉右缘导致图往右偏),
|
||||
// 交给后面 setVisibleRange 一次锁定位置+缩放。
|
||||
if (!pendingView) {
|
||||
baseOptions.barSpacing = symbolConfig.type === 'a_stock' ? 6 : 10;
|
||||
baseOptions.rightOffset = 12;
|
||||
}
|
||||
|
||||
console.log('🔧 同步时间轴设置:', baseOptions);
|
||||
|
||||
// 应用相同的设置到所有图表
|
||||
mainChart.timeScale().applyOptions(baseOptions);
|
||||
volumeChart.timeScale().applyOptions(baseOptions);
|
||||
atrChart.timeScale().applyOptions(baseOptions);
|
||||
if (showMacd && macdChart) {
|
||||
macdChart.timeScale().applyOptions(baseOptions);
|
||||
}
|
||||
};
|
||||
|
||||
// 首先同步时间轴设置
|
||||
syncTimeScaleSettings();
|
||||
|
||||
// 仅在没有待恢复视图时,设置默认可见范围
|
||||
const totalBars = candles ? candles.length : 0;
|
||||
const visibleBarsCount = 200;
|
||||
const allChartsNow = [mainChart, volumeChart, atrChart]
|
||||
.concat(showMacd && macdChart ? [macdChart] : [])
|
||||
.concat(showMacd && chanMacdChart ? [chanMacdChart] : []);
|
||||
if (hasPendingRestoreView && pendingView) {
|
||||
restoreChartViewState(allChartsNow, pendingView, { preferTime: true });
|
||||
} else {
|
||||
// 显示最近 200 根K线而非全部挤压(避免K线过多时重叠)
|
||||
if (totalBars > visibleBarsCount) {
|
||||
const rangeFrom = totalBars - visibleBarsCount;
|
||||
const rangeTo = totalBars + 12;
|
||||
mainChart.timeScale().setVisibleLogicalRange({ from: rangeFrom, to: rangeTo });
|
||||
} else {
|
||||
mainChart.timeScale().fitContent();
|
||||
}
|
||||
}
|
||||
|
||||
// 立即同步其他图表到主图表的范围(无 pending 时)
|
||||
setTimeout(() => {
|
||||
if (window._pendingRestoreView) {
|
||||
restoreChartViewState(allChartsNow, window._pendingRestoreView, { preferTime: true });
|
||||
return;
|
||||
}
|
||||
const logRange = mainChart.timeScale().getVisibleLogicalRange();
|
||||
if (logRange) {
|
||||
console.log('🔧 同步可见范围:', logRange);
|
||||
volumeChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
atrChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
if (showMacd && macdChart) {
|
||||
macdChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
}
|
||||
if (showMacd && chanMacdChart) {
|
||||
chanMacdChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
}
|
||||
console.log('🔧 时间轴同步完成');
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// 保存图表对象
|
||||
tvWidget.mainChart = mainChart;
|
||||
tvWidget.volumeChart = volumeChart;
|
||||
tvWidget.atrChart = atrChart;
|
||||
tvWidget.macdChart = macdChart;
|
||||
tvWidget.chanMacdChart = chanMacdChart;
|
||||
tvWidget.state.isInitialized = true;
|
||||
// 注册窗口卸载时释放资源,避免GPU内存泄漏
|
||||
window.onbeforeunload = function() {
|
||||
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();
|
||||
if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') tvWidget.macdChart.remove();
|
||||
if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') tvWidget.chanMacdChart.remove();
|
||||
if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') tvWidget.atrChart.remove();
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
// 初始化默认均线/布林带配置(仅在首次初始化时)
|
||||
if (!hasInitializedDefaultMAs && movingAverages.length === 0) {
|
||||
console.log('初始化默认均线与布林带指标');
|
||||
|
||||
if (typeof maIdCounter !== 'number' || !Number.isFinite(maIdCounter)) {
|
||||
maIdCounter = 0;
|
||||
}
|
||||
if (typeof bbIdCounter !== 'number' || !Number.isFinite(bbIdCounter)) {
|
||||
bbIdCounter = 0;
|
||||
}
|
||||
|
||||
const defaultMAs = [
|
||||
{ type: 'EMA', length: 26, color: '#FF8C00', name: 'EMA26', visible: false }, // 橙色
|
||||
{ type: 'EMA', length: 52, color: '#000000', name: 'EMA52', visible: true }, // 黑色 · 默认开
|
||||
{ type: 'SMA', length: 30, color: '#1E90FF', name: 'MA30', visible: true }, // 蓝色 · 默认开
|
||||
{ type: 'SMA', length: 250, color: '#800080', name: 'MA250', visible: true } // 紫色 · 默认开
|
||||
];
|
||||
|
||||
defaultMAs.forEach(ma => {
|
||||
const config = {
|
||||
id: ++maIdCounter,
|
||||
type: ma.type,
|
||||
length: ma.length,
|
||||
source: 'close',
|
||||
smoothType: 'none',
|
||||
smoothLength: 3,
|
||||
lineWidth: 1, // 1px线宽
|
||||
lineStyle: 0, // 实线
|
||||
color: ma.color,
|
||||
visible: ma.visible
|
||||
};
|
||||
|
||||
movingAverages.push(config);
|
||||
console.log(`添加默认${ma.name}:`, ma.color);
|
||||
});
|
||||
|
||||
if (bollingerBands.length === 0) {
|
||||
const defaultBB = {
|
||||
id: ++bbIdCounter,
|
||||
type: 'Bollinger Bands',
|
||||
length: 20,
|
||||
upperMultiplier: 2,
|
||||
lowerMultiplier: 2,
|
||||
source: 'close',
|
||||
lineWidth: 1,
|
||||
lineStyle: 0,
|
||||
upperColor: '#ff6b6b',
|
||||
middleColor: '#ffffff',
|
||||
lowerColor: '#ff6b6b',
|
||||
visible: false
|
||||
};
|
||||
bollingerBands.push(defaultBB);
|
||||
console.log('添加默认布林带: BB(20, 2, 2)');
|
||||
}
|
||||
|
||||
console.log('默认指标配置完成,当前均线数量', movingAverages.length, '布林带数量', bollingerBands.length);
|
||||
hasInitializedDefaultMAs = true;
|
||||
}
|
||||
|
||||
// 添加均线到图表
|
||||
addMovingAveragesToChart(candles);
|
||||
|
||||
// 添加布林带到图表
|
||||
addBollingerBandsToChart(candles);
|
||||
|
||||
// 更新技术指标面板显示
|
||||
updateIndicatorPanel();
|
||||
|
||||
// 绑定同步事件
|
||||
bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, mainChart, volumeChart, atrChart, macdChart, chanMacdChart, showMacd);
|
||||
|
||||
// 最终确保所有图表时间轴对齐(同时恢复刷新前保存的缩放/位置)
|
||||
setTimeout(() => {
|
||||
const allCharts = [mainChart, volumeChart, atrChart];
|
||||
if (showMacd && macdChart) allCharts.push(macdChart);
|
||||
if (showMacd && chanMacdChart) allCharts.push(chanMacdChart);
|
||||
|
||||
// 检查是否有待恢复的视图(缩放 + 位置)
|
||||
const pending = window._pendingRestoreView;
|
||||
window._pendingRestoreView = null;
|
||||
|
||||
if (pending) {
|
||||
// 恢复刷新前的缩放和位置(时间范围优先,避免数据滑动后逻辑索引错位)
|
||||
console.log('📌 恢复图表视图:', JSON.stringify(pending));
|
||||
restoreChartViewState(allCharts, pending, { preferTime: true });
|
||||
} else {
|
||||
// 无保存视图,正常同步主图到子图
|
||||
const visibleRange = mainChart.timeScale().getVisibleRange();
|
||||
if (visibleRange) {
|
||||
console.log('🔧 最终同步可见范围:', visibleRange);
|
||||
[volumeChart, atrChart].concat(
|
||||
showMacd && macdChart ? [macdChart] : [],
|
||||
showMacd && chanMacdChart ? [chanMacdChart] : []
|
||||
).forEach(c => {
|
||||
try { c.timeScale().setVisibleRange(visibleRange); } catch(e) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('🔧 最终时间轴对齐完成');
|
||||
}, 150);
|
||||
// 只有在时间输入框都为空时才设置图表默认时间范围
|
||||
if (!$('#start_time').val() && !$('#end_time').val()) {
|
||||
setDefaultTimeRange();
|
||||
}
|
||||
|
||||
// 添加买卖点提示
|
||||
// 初始化 tooltip 与 U 显示状态
|
||||
window.showUOnMain = $('#toggleUOnMain').is(':checked');
|
||||
window.showUOnElement = $('#toggleUOnElement').is(':checked');
|
||||
setupTooltip(mainChart, [], [], mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, volumeChart, atrChart, macdChart, chanMacdChart, showMacd);
|
||||
|
||||
// 更新EMA52显示
|
||||
if (currentData) {
|
||||
updateEMA52Display(currentData);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
/* chart_tv_indicators.js — volume / ATR / ChanMACD */
|
||||
|
||||
function chartTvRenderIndicators(ctx) {
|
||||
var symbol = ctx.symbol;
|
||||
var timeframe = ctx.timeframe;
|
||||
var symbolConfig = ctx.symbolConfig;
|
||||
var useSubSubPeriod = ctx.useSubSubPeriod;
|
||||
var useElementPeriod = ctx.useElementPeriod;
|
||||
var klinePeriodLabel = ctx.klinePeriodLabel;
|
||||
var candles = ctx.candles;
|
||||
var klineDataSource = ctx.klineDataSource;
|
||||
var container = ctx.container;
|
||||
var showMacd = ctx.showMacd;
|
||||
var showOriginalKline = ctx.showOriginalKline;
|
||||
var mainChartContainer = ctx.mainChartContainer;
|
||||
var volumeChartContainer = ctx.volumeChartContainer;
|
||||
var atrChartContainer = ctx.atrChartContainer;
|
||||
var macdChartContainer = ctx.macdChartContainer;
|
||||
var chanMacdChartContainer = ctx.chanMacdChartContainer;
|
||||
var mainChart = ctx.mainChart;
|
||||
var volumeChart = ctx.volumeChart;
|
||||
var atrChart = ctx.atrChart;
|
||||
var macdChart = ctx.macdChart;
|
||||
var chanMacdChart = ctx.chanMacdChart;
|
||||
var createChartOptions = ctx.createChartOptions;
|
||||
// 转换成交量数据 - 与K线周期一致
|
||||
let volumes = [];
|
||||
const volumeDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
|
||||
|
||||
console.log('成交量数据源选择:', klinePeriodLabel);
|
||||
console.log('成交量数据长度:', volumeDataSource.length);
|
||||
|
||||
if (volumeDataSource && Array.isArray(volumeDataSource)) {
|
||||
volumes = volumeDataSource.map(kline => {
|
||||
// 使用与K线和MACD完全相同的时间戳计算方式
|
||||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||||
return {
|
||||
time: timestamp,
|
||||
value: parseFloat(kline.volume),
|
||||
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)',
|
||||
};
|
||||
});
|
||||
|
||||
console.log('处理后的成交量数据点数:', volumes.length);
|
||||
}
|
||||
|
||||
// 添加成交量图表
|
||||
const volumeSeries = volumeChart.addHistogramSeries({
|
||||
color: '#26a69a',
|
||||
priceFormat: {
|
||||
type: 'volume',
|
||||
},
|
||||
title: '成交量',
|
||||
});
|
||||
volumeSeries.setData(volumes);
|
||||
tvWidget.series.volumeSeries = volumeSeries;
|
||||
|
||||
// 添加ATR图表
|
||||
const atrLineSeries = atrChart.addLineSeries({
|
||||
color: '#FF9800',
|
||||
lineWidth: 2,
|
||||
title: 'ATR',
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
});
|
||||
|
||||
// 准备ATR数据
|
||||
const atrData = [];
|
||||
const atrKlineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
|
||||
const atrDataSource = useSubSubPeriod ? (currentData.sub_sub_atr || currentData.atr) : (useElementPeriod ? (currentData.element_atr || currentData.atr) : currentData.atr);
|
||||
|
||||
console.log('ATR数据源选择:', klinePeriodLabel);
|
||||
console.log('ATR数据长度:', atrDataSource ? atrDataSource.length : 0);
|
||||
console.log('K线数据长度:', atrKlineDataSource ? atrKlineDataSource.length : 0);
|
||||
|
||||
if (atrDataSource && Array.isArray(atrDataSource) && atrKlineDataSource && Array.isArray(atrKlineDataSource)) {
|
||||
// 关键修复:为每个K线时间点都创建ATR数据点,包括没有ATR值的前期数据
|
||||
for (let i = 0; i < atrKlineDataSource.length; i++) {
|
||||
const kline = atrKlineDataSource[i];
|
||||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||||
|
||||
// 为每个时间点都添加数据以保持时间轴对齐,但ATR为0时不显示
|
||||
if (atrDataSource[i] !== undefined) {
|
||||
if (atrDataSource[i] > 0) {
|
||||
// ATR有效值,正常显示
|
||||
atrData.push({
|
||||
time: timestamp,
|
||||
value: atrDataSource[i]
|
||||
});
|
||||
} else {
|
||||
// ATR为0,添加时间点但不显示线条(使用undefined作为value)
|
||||
atrData.push({
|
||||
time: timestamp,
|
||||
value: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('处理后的ATR数据点数:', atrData.length);
|
||||
console.log('ATR数据样本:', atrData.slice(0, 5));
|
||||
}
|
||||
console.log('处理后的ATR数据点数:', atrData.length);
|
||||
atrLineSeries.setData(atrData);
|
||||
tvWidget.series.atrLineSeries = atrLineSeries;
|
||||
|
||||
// 旧 MACD 图已移除,不再绘制(保留占位但彻底禁用)
|
||||
if (FEATURES.legacyMacd && showMacd && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) {
|
||||
// 创建MACD线
|
||||
const macdLineSeries = macdChart.addLineSeries({
|
||||
color: '#2962FF',
|
||||
lineWidth: 1,
|
||||
title: 'MACD',
|
||||
lastValueVisible: false, // 禁用最后值标签,防止遮挡
|
||||
priceLineVisible: false, // 禁用价格线
|
||||
});
|
||||
|
||||
// 创建信号线
|
||||
const signalLineSeries = macdChart.addLineSeries({
|
||||
color: '#FF6B6B',
|
||||
lineWidth: 1,
|
||||
title: 'Signal',
|
||||
lastValueVisible: false, // 禁用最后值标签,防止遮挡
|
||||
priceLineVisible: false, // 禁用价格线
|
||||
});
|
||||
|
||||
// 创建直方图
|
||||
const histogramSeries = macdChart.addHistogramSeries({
|
||||
color: '#26a69a',
|
||||
title: 'Histogram',
|
||||
priceFormat: {
|
||||
type: 'price',
|
||||
precision: 4,
|
||||
},
|
||||
});
|
||||
|
||||
// 提取MACD数据 - 使用和K线数据相同的时间处理逻辑
|
||||
const macdData = [];
|
||||
const signalData = [];
|
||||
const histogramData = [];
|
||||
|
||||
// 使用与K线数据相同的数据源来确保时间对齐
|
||||
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
|
||||
const macdDataSource = useElementPeriod ?
|
||||
(currentData.element_macd || currentData.macd) : // 如果有次周期MACD数据则使用,否则使用主周期
|
||||
currentData.macd; // 主周期使用主周期MACD数据
|
||||
|
||||
console.log('MACD数据源选择:', useElementPeriod ? '次周期' : '主周期');
|
||||
console.log('K线数据长度:', klineDataSource.length);
|
||||
console.log('MACD数据:', macdDataSource);
|
||||
|
||||
for (let i = 0; i < klineDataSource.length; i++) {
|
||||
const kline = klineDataSource[i];
|
||||
// 使用与K线完全相同的时间戳计算方式
|
||||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||||
|
||||
if (macdDataSource && macdDataSource.macd && macdDataSource.macd[i] !== undefined) {
|
||||
macdData.push({
|
||||
time: timestamp,
|
||||
value: macdDataSource.macd[i]
|
||||
});
|
||||
|
||||
signalData.push({
|
||||
time: timestamp,
|
||||
value: macdDataSource.signal[i]
|
||||
});
|
||||
|
||||
// 设置直方图颜色
|
||||
const histValue = macdDataSource.histogram[i];
|
||||
histogramData.push({
|
||||
time: timestamp,
|
||||
value: histValue,
|
||||
color: histValue >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('处理后的MACD数据点数:', macdData.length);
|
||||
|
||||
macdLineSeries.setData(macdData);
|
||||
signalLineSeries.setData(signalData);
|
||||
histogramSeries.setData(histogramData);
|
||||
|
||||
tvWidget.series.macdLineSeries = macdLineSeries;
|
||||
tvWidget.series.signalLineSeries = signalLineSeries;
|
||||
tvWidget.series.histogramSeries = histogramSeries;
|
||||
}
|
||||
// 添加ChanMACD图表
|
||||
console.log('ChanMACD图表创建条件检查:', {
|
||||
showMacd: showMacd,
|
||||
chanMacdChart: !!chanMacdChart,
|
||||
hasMacd: !!currentData.macd,
|
||||
hasKlineData: !!currentData.kline_data,
|
||||
isArray: Array.isArray(currentData.kline_data)
|
||||
});
|
||||
// 在创建 ChanMACD 前,确保一次性同步 U 显示开关到全局(默认不显示)
|
||||
if (typeof window.showUOnMain === 'undefined') {
|
||||
window.showUOnMain = $('#toggleUOnMain').is(':checked');
|
||||
}
|
||||
if (typeof window.showUOnElement === 'undefined') {
|
||||
window.showUOnElement = $('#toggleUOnElement').is(':checked');
|
||||
}
|
||||
if (typeof window.showUOnSubSub === 'undefined') {
|
||||
window.showUOnSubSub = $('#toggleUOnSubSub').is(':checked');
|
||||
}
|
||||
if (showMacd && chanMacdChart && ((useSubSubPeriod && currentData.sub_sub_macd) || (useElementPeriod && currentData.element_macd) || currentData.macd) && (useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data))) {
|
||||
console.log('✅ 开始创建 ChanMACD 系列');
|
||||
// 创建ChanMACD线系列
|
||||
const chanMacdLineSeries = chanMacdChart.addLineSeries({
|
||||
color: '#2962FF',
|
||||
lineWidth: 1,
|
||||
title: 'ChanMACD',
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
});
|
||||
|
||||
// 创建ChanMACD信号线系列
|
||||
const chanMacdSignalSeries = chanMacdChart.addLineSeries({
|
||||
color: '#FF6B6B',
|
||||
lineWidth: 1,
|
||||
title: 'ChanSignal',
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
});
|
||||
|
||||
// 创建ChanMACD柱状图系列
|
||||
const chanMacdHistSeries = chanMacdChart.addHistogramSeries({
|
||||
color: '#26a69a',
|
||||
title: 'ChanHistogram',
|
||||
priceFormat: {
|
||||
type: 'price',
|
||||
precision: 4,
|
||||
},
|
||||
});
|
||||
|
||||
// 设置ChanMACD图表的字体大小
|
||||
chanMacdChart.applyOptions({
|
||||
layout: {
|
||||
fontSize: 10, // 设置更小的字体大小
|
||||
},
|
||||
rightPriceScale: {
|
||||
fontSize: 10, // 设置右侧价格轴的字体大小
|
||||
},
|
||||
timeScale: {
|
||||
fontSize: 10, // 设置时间轴的字体大小
|
||||
},
|
||||
});
|
||||
|
||||
// 使用与主图一致的数据源(小周期开启时使用小周期MACD与K线)
|
||||
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
|
||||
const macdDataSource = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd);
|
||||
|
||||
// 准备ChanMACD数据
|
||||
const chanMacdData = [];
|
||||
const chanSignalData = [];
|
||||
const chanHistData = [];
|
||||
|
||||
console.log('ChanMACD数据源检查:', {
|
||||
klineDataSourceLength: klineDataSource.length,
|
||||
macdDataSource: !!macdDataSource,
|
||||
macdLength: macdDataSource ? macdDataSource.macd.length : 0
|
||||
});
|
||||
|
||||
console.log('ChanMACD数据源检查:', {
|
||||
klineDataSourceLength: klineDataSource.length,
|
||||
macdDataSource: !!macdDataSource,
|
||||
macdLength: macdDataSource ? macdDataSource.macd.length : 0
|
||||
});
|
||||
|
||||
for (let i = 0; i < klineDataSource.length; i++) {
|
||||
const kline = klineDataSource[i];
|
||||
if (kline && kline.date &&
|
||||
i < macdDataSource.macd.length &&
|
||||
macdDataSource.macd[i] !== null && macdDataSource.macd[i] !== undefined) {
|
||||
|
||||
// 使用与K线完全相同的时间戳计算方式
|
||||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||||
|
||||
chanMacdData.push({
|
||||
time: timestamp,
|
||||
value: macdDataSource.macd[i]
|
||||
});
|
||||
|
||||
chanSignalData.push({
|
||||
time: timestamp,
|
||||
value: macdDataSource.signal[i]
|
||||
});
|
||||
|
||||
chanHistData.push({
|
||||
time: timestamp,
|
||||
value: macdDataSource.histogram[i],
|
||||
color: macdDataSource.histogram[i] >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('ChanMACD数据处理完成:', {
|
||||
chanMacdDataLength: chanMacdData.length,
|
||||
chanSignalDataLength: chanSignalData.length,
|
||||
chanHistDataLength: chanHistData.length,
|
||||
sampleData: chanMacdData.length > 0 ? chanMacdData[0] : null
|
||||
});
|
||||
|
||||
// 设置ChanMACD数据
|
||||
console.log('ChanMACD数据长度:', chanMacdData.length, chanSignalData.length, chanHistData.length);
|
||||
|
||||
if (chanMacdData.length > 0) {
|
||||
chanMacdLineSeries.setData(chanMacdData);
|
||||
chanMacdSignalSeries.setData(chanSignalData);
|
||||
chanMacdHistSeries.setData(chanHistData);
|
||||
console.log('✅ ChanMACD数据设置成功');
|
||||
} else {
|
||||
console.warn('⚠️ ChanMACD数据为空,无法设置数据');
|
||||
}
|
||||
|
||||
// 保存到tvWidget
|
||||
tvWidget.series.chanMacdLineSeries = chanMacdLineSeries;
|
||||
tvWidget.series.chanMacdSignalSeries = chanMacdSignalSeries;
|
||||
tvWidget.series.chanMacdHistSeries = chanMacdHistSeries;
|
||||
|
||||
console.log('✅ ChanMACD图表系列已保存到tvWidget');
|
||||
|
||||
// 添加ChanMACD分析标注
|
||||
// 根据主/次周期开关与各自的"显示U"独立控制
|
||||
const cm = useSubSubPeriod ? (currentData.sub_sub_chan_macd || currentData.chan_macd) : (useElementPeriod ? (currentData.element_chan_macd || currentData.chan_macd) : currentData.chan_macd);
|
||||
// 默认不显示,必须用户勾选对应复选框
|
||||
const allowU = useSubSubPeriod ? !!window.showUOnSubSub : (useElementPeriod ? !!window.showUOnElement : !!window.showUOnMain);
|
||||
if (cm && allowU) {
|
||||
console.log('添加ChanMACD分析标注:', {
|
||||
segListLength: cm.seg_list ? cm.seg_list.length : 0,
|
||||
unittfListLength: cm.unittf_list ? cm.unittf_list.length : 0,
|
||||
histsetListLength: cm.histset_list ? cm.histset_list.length : 0
|
||||
});
|
||||
|
||||
// 详细检查段数据
|
||||
if (cm.seg_list && cm.seg_list.length > 0) {
|
||||
console.log('段数据详情:', cm.seg_list.slice(0, 3)); // 显示前3个段
|
||||
} else {
|
||||
console.log('⚠️ 段数据为空或不存在');
|
||||
}
|
||||
|
||||
addAllChanMacdMarkers(
|
||||
cm.seg_list || [],
|
||||
cm.unittf_list || [],
|
||||
cm.histset_list || [],
|
||||
{
|
||||
high_position_list: cm.high_position_list || [],
|
||||
high_empty_list: cm.high_empty_list || [],
|
||||
low_position_list: cm.low_position_list || [],
|
||||
low_empty_list: cm.low_empty_list || [],
|
||||
return_zero_list: cm.return_zero_list || [],
|
||||
cross0_up_list: cm.cross0_up_list || [],
|
||||
cross0_down_list: cm.cross0_down_list || []
|
||||
}
|
||||
);
|
||||
|
||||
// 同时从主/次周期的 klu_list 提取 SD/CD 标记,分别使用不同样式
|
||||
try {
|
||||
const mainCm = currentData.chan_macd || {};
|
||||
const elementCm = currentData.element_chan_macd || {};
|
||||
|
||||
const mainMarkers = [];
|
||||
const elementMarkers = [];
|
||||
|
||||
// 基于时间构建 MACD 值映射,便于按时间快速获取对应的 MACD 值
|
||||
const buildMacdTimeMap = (macdObj, klineArr) => {
|
||||
const map = new Map();
|
||||
if (!macdObj || !klineArr || !Array.isArray(klineArr)) return map;
|
||||
for (let i = 0; i < klineArr.length; i++) {
|
||||
const k = klineArr[i];
|
||||
if (!k || !k.date) continue;
|
||||
const t = Math.floor(new Date(k.date).getTime() / 1000);
|
||||
const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null;
|
||||
map.set(t, val);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const mainMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data);
|
||||
const elementMacdMap = buildMacdTimeMap(
|
||||
(currentData.element_macd || currentData.macd),
|
||||
(currentData.element_kline_data || currentData.kline_data)
|
||||
);
|
||||
|
||||
// 主周期 U 标记(蓝/橙,与原样式一致)
|
||||
if (window.showUOnMain && Array.isArray(mainCm.klu_list)) {
|
||||
mainCm.klu_list.forEach((item) => {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
const macdVal = mainMacdMap.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
mainMarkers.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = mainMacdMap.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
mainMarkers.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
mainMarkers.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 次周期 U 标记(使用不同配色以区分)
|
||||
if (window.showUOnElement && Array.isArray(elementCm.klu_list)) {
|
||||
elementCm.klu_list.forEach((item) => {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
const macdVal = elementMacdMap.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
elementMarkers.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = elementMacdMap.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
elementMarkers.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
elementMarkers.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const subSubCm = currentData.sub_sub_chan_macd || {};
|
||||
const subSubMarkers = [];
|
||||
const subSubMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data);
|
||||
if (window.showUOnSubSub && Array.isArray(subSubCm.klu_list)) {
|
||||
subSubCm.klu_list.forEach((item) => {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
const macdVal = subSubMacdMap.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
subSubMarkers.push({ time: ts, position: posSd, color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = subSubMacdMap.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
subSubMarkers.push({ time: ts, position: posCd, color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
subSubMarkers.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
}
|
||||
});
|
||||
}
|
||||
window.kluDivMarkersSubSub = subSubMarkers;
|
||||
|
||||
// 保存到全局,供主图合并标记使用
|
||||
window.kluDivMarkersMain = mainMarkers;
|
||||
window.kluDivMarkersElement = elementMarkers;
|
||||
} catch (e) {
|
||||
console.warn('处理 KLU 背驰标记出错:', e);
|
||||
window.kluDivMarkersMain = [];
|
||||
window.kluDivMarkersElement = [];
|
||||
window.kluDivMarkersSubSub = [];
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ 没有ChanMACD分析数据');
|
||||
// 无数据时清空本次的 KLU 背驰标记
|
||||
window.kluDivMarkersMain = [];
|
||||
window.kluDivMarkersElement = [];
|
||||
window.kluDivMarkersSubSub = [];
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ ChanMACD图表创建条件不满足');
|
||||
}
|
||||
// 独立于当前显示周期:计算主/次周期 SD/CD 标记(用于主图合并显示)
|
||||
try {
|
||||
const mainCmAll = currentData.chan_macd || {};
|
||||
const elementCmAll = currentData.element_chan_macd || {};
|
||||
const mainMarkersAll = [];
|
||||
const elementMarkersAll = [];
|
||||
|
||||
// 构建 MACD 时间映射,用于依据 MACD 正负决定 SD/CD 的显示上下位置
|
||||
const buildMacdTimeMapAll = (macdObj, klineArr) => {
|
||||
const map = new Map();
|
||||
if (!macdObj || !klineArr || !Array.isArray(klineArr)) return map;
|
||||
for (let i = 0; i < klineArr.length; i++) {
|
||||
const k = klineArr[i];
|
||||
if (!k || !k.date) continue;
|
||||
const t = Math.floor(new Date(k.date).getTime() / 1000);
|
||||
const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null;
|
||||
map.set(t, val);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const mainMacdMapAll = buildMacdTimeMapAll(currentData.macd, currentData.kline_data);
|
||||
const elementMacdMapAll = buildMacdTimeMapAll(
|
||||
(currentData.element_macd || currentData.macd),
|
||||
(currentData.element_kline_data || currentData.kline_data)
|
||||
);
|
||||
if ((typeof window.showUOnMain === 'undefined' ? false : window.showUOnMain) && Array.isArray(mainCmAll.klu_list)) {
|
||||
mainCmAll.klu_list.forEach((item) => {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
const macdVal = mainMacdMapAll.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
mainMarkersAll.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = mainMacdMapAll.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
mainMarkersAll.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
mainMarkersAll.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
}
|
||||
});
|
||||
}
|
||||
if ((typeof window.showUOnElement === 'undefined' ? false : window.showUOnElement) && Array.isArray(elementCmAll.klu_list)) {
|
||||
elementCmAll.klu_list.forEach((item) => {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
const macdVal = elementMacdMapAll.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
elementMarkersAll.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = elementMacdMapAll.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
elementMarkersAll.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
elementMarkersAll.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
}
|
||||
});
|
||||
}
|
||||
window.kluDivMarkersMain = mainMarkersAll;
|
||||
window.kluDivMarkersElement = elementMarkersAll;
|
||||
const subSubCmAll = currentData.sub_sub_chan_macd || {};
|
||||
const subSubMarkersAll = [];
|
||||
if (window.showUOnSubSub && Array.isArray(subSubCmAll.klu_list)) {
|
||||
subSubCmAll.klu_list.forEach((item) => {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
subSubMarkersAll.push({ time: ts, position: 'aboveBar', color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
}
|
||||
});
|
||||
}
|
||||
window.kluDivMarkersSubSub = subSubMarkersAll;
|
||||
} catch (e) {
|
||||
console.warn('独立计算 KLU 背驰标记出错:', e);
|
||||
window.kluDivMarkersMain = [];
|
||||
window.kluDivMarkersElement = [];
|
||||
window.kluDivMarkersSubSub = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* chart_tv_lifecycle.js — dispose Lightweight Charts / DOM / listeners */
|
||||
|
||||
/** 释放 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) {
|
||||
// 重建前救出 Cycle Summary,避免 innerHTML 清空时被销毁
|
||||
var summaryEl = document.getElementById('wyckoffCycleSummary');
|
||||
var chartHost = chartRoot.parentElement;
|
||||
if (summaryEl && chartRoot.contains(summaryEl) && chartHost) {
|
||||
chartHost.appendChild(summaryEl);
|
||||
}
|
||||
chartRoot.innerHTML = '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('disposeTradingViewCharts 失败(可忽略):', e);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,519 @@
|
||||
/* chart_tv_shell.js — containers, charts, main price series */
|
||||
|
||||
function chartTvBuildShell(ctx) {
|
||||
var symbol = ctx.symbol;
|
||||
var timeframe = ctx.timeframe;
|
||||
|
||||
// 获取当前交易对的配置
|
||||
const symbolConfig = getSymbolConfig(symbol);
|
||||
console.log('交易对配置:', symbolConfig);
|
||||
|
||||
// 检查数据是否存在
|
||||
if (!currentData || !currentData.kline_data) {
|
||||
console.error('数据加载失败或不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查使用哪一档K线数据:次次周期 / 小周期 / 主周期
|
||||
const useSubSubPeriod = $('#subSubPeriodKline').is(':checked') &&
|
||||
currentData.sub_sub_kline_data &&
|
||||
Array.isArray(currentData.sub_sub_kline_data);
|
||||
const useElementPeriod = $('#elementPeriodKline').is(':checked') &&
|
||||
currentData.element_kline_data &&
|
||||
Array.isArray(currentData.element_kline_data);
|
||||
|
||||
// 输出K线周期选择状态
|
||||
const klinePeriodLabel = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期');
|
||||
console.log('K线周期选择:', klinePeriodLabel);
|
||||
console.log('当前选择时区:', $('#timezone').val());
|
||||
console.log('交易对类型:', symbolConfig.type);
|
||||
|
||||
let candles = [];
|
||||
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? (currentData.element_kline_data || []) : (currentData.kline_data || []));
|
||||
|
||||
if (useSubSubPeriod || useElementPeriod) {
|
||||
if (!klineDataSource.length) {
|
||||
console.error(useSubSubPeriod ? '次次周期K线数据不存在或为空' : '小周期K线数据不存在或为空', klineDataSource);
|
||||
return;
|
||||
}
|
||||
candles = klineDataSource.map((kline) => {
|
||||
const date = new Date(kline.date);
|
||||
const timestamp = Math.floor(date.getTime() / 1000);
|
||||
return {
|
||||
time: timestamp,
|
||||
open: parseFloat(kline.open),
|
||||
high: parseFloat(kline.high),
|
||||
low: parseFloat(kline.low),
|
||||
close: parseFloat(kline.close),
|
||||
};
|
||||
}).filter((c) => isFinite(c.time) && isFinite(c.open) && isFinite(c.high) && isFinite(c.low) && isFinite(c.close));
|
||||
} else {
|
||||
if (!currentData.kline_data || !Array.isArray(currentData.kline_data)) {
|
||||
console.error('主周期K线数据不存在或不是数组:', currentData.kline_data);
|
||||
return;
|
||||
}
|
||||
candles = currentData.kline_data.map((kline) => {
|
||||
const date = new Date(kline.date);
|
||||
const timestamp = Math.floor(date.getTime() / 1000);
|
||||
return {
|
||||
time: timestamp,
|
||||
open: parseFloat(kline.open),
|
||||
high: parseFloat(kline.high),
|
||||
low: parseFloat(kline.low),
|
||||
close: parseFloat(kline.close),
|
||||
};
|
||||
}).filter((c) => isFinite(c.time) && isFinite(c.open) && isFinite(c.high) && isFinite(c.low) && isFinite(c.close));
|
||||
}
|
||||
|
||||
// 根据交易对类型过滤数据(仅用于显示优化)
|
||||
if (symbolConfig.type === 'a_stock' && timeframe.includes('m')) {
|
||||
// 对于A股分钟级数据,过滤非交易时间
|
||||
const originalLength = candles.length;
|
||||
candles = filterTradingHours(candles, symbolConfig);
|
||||
console.log(`A股数据过滤: ${originalLength} -> ${candles.length} 条记录`);
|
||||
}
|
||||
// 重置图表对象(容器已在 disposeTradingViewCharts 清空)
|
||||
tvWidget = {
|
||||
mainChart: null,
|
||||
volumeChart: null,
|
||||
macdChart: null,
|
||||
series: {
|
||||
candleSeries: null,
|
||||
lineSeries: null,
|
||||
volumeSeries: null,
|
||||
macdLineSeries: null,
|
||||
signalLineSeries: null,
|
||||
histogramSeries: null,
|
||||
mainBiSeries: [],
|
||||
mainUncompletedBiSeries: [],
|
||||
mainSegSeries: [],
|
||||
mainUncompletedSegSeries: [],
|
||||
mainZsSeries: [],
|
||||
mainUncompletedZsSeries: [],
|
||||
elementBiSeries: [],
|
||||
elementUncompletedBiSeries: [],
|
||||
elementSegSeries: [],
|
||||
elementUncompletedSegSeries: [],
|
||||
elementZsSeries: [],
|
||||
elementUncompletedZsSeries: [],
|
||||
subSubBiSeries: [],
|
||||
subSubUncompletedBiSeries: [],
|
||||
subSubSegSeries: [],
|
||||
subSubUncompletedSegSeries: [],
|
||||
subSubZsSeries: [],
|
||||
subSubUncompletedZsSeries: [],
|
||||
tradePointSeries: [],
|
||||
mainBollingerSeries: [],
|
||||
elementBollingerSeries: [],
|
||||
maSeries: [], // 添加均线系列数组
|
||||
bbSeries: [], // 添加布林带系列数组
|
||||
ema52Series: [] // 添加EMA52系列数组
|
||||
},
|
||||
state: {
|
||||
isInitialized: false,
|
||||
visibleRange: null,
|
||||
logicalRange: null
|
||||
}
|
||||
};
|
||||
|
||||
// 设置父容器样式
|
||||
const container = document.getElementById('tradingview_chart');
|
||||
container.style.position = 'relative';
|
||||
container.style.width = '100%';
|
||||
container.style.height = '100%';
|
||||
|
||||
// 是否显示MACD
|
||||
const showMacd = $('#showMacd').is(':checked');
|
||||
const showOriginalKline = $('#showOriginalKline').is(':checked');
|
||||
|
||||
// 创建主图容器
|
||||
const mainChartContainer = document.createElement('div');
|
||||
mainChartContainer.style.width = '100%';
|
||||
mainChartContainer.style.position = 'absolute';
|
||||
mainChartContainer.style.top = '0';
|
||||
mainChartContainer.style.left = '0';
|
||||
mainChartContainer.style.right = '0';
|
||||
|
||||
// 创建成交量副图容器
|
||||
const volumeChartContainer = document.createElement('div');
|
||||
volumeChartContainer.style.width = '100%';
|
||||
volumeChartContainer.style.position = 'absolute';
|
||||
volumeChartContainer.style.left = '0';
|
||||
volumeChartContainer.style.right = '0';
|
||||
volumeChartContainer.style.borderTop = '1px solid #e0e0e0';
|
||||
|
||||
// 添加ATR图表容器
|
||||
const atrChartContainer = document.createElement('div');
|
||||
atrChartContainer.style.width = '100%';
|
||||
atrChartContainer.style.position = 'absolute';
|
||||
atrChartContainer.style.left = '0';
|
||||
atrChartContainer.style.right = '0';
|
||||
atrChartContainer.style.borderTop = '1px solid #e0e0e0';
|
||||
|
||||
// 如果需要显示MACD,创建MACD容器
|
||||
let macdChartContainer = null;
|
||||
let chanMacdChartContainer = null;
|
||||
if (showMacd) {
|
||||
// 仅显示新的 ChanMACD 图:让其占用原 MACD+ChanMACD 的整体高度
|
||||
// 新布局:主图(40%) → ChanMACD(30%) → 成交量(17.5%) → ATR(12.5%)
|
||||
mainChartContainer.style.height = '40%';
|
||||
|
||||
// 隐藏旧 MACD 容器(不创建)
|
||||
// 创建 ChanMACD 容器占据原 MACD+ChanMACD 高度(30%)
|
||||
chanMacdChartContainer = document.createElement('div');
|
||||
chanMacdChartContainer.style.width = '100%';
|
||||
chanMacdChartContainer.style.height = '30%';
|
||||
chanMacdChartContainer.style.position = 'absolute';
|
||||
chanMacdChartContainer.style.top = '40%';
|
||||
chanMacdChartContainer.style.left = '0';
|
||||
chanMacdChartContainer.style.right = '0';
|
||||
chanMacdChartContainer.style.borderTop = '1px solid #e0e0e0';
|
||||
chanMacdChartContainer.style.zIndex = '10';
|
||||
// 水印:便于区分是新的 ChanMACD 子图
|
||||
const chanMacdWatermark = document.createElement('div');
|
||||
chanMacdWatermark.textContent = 'ChanMACD';
|
||||
chanMacdWatermark.style.position = 'absolute';
|
||||
chanMacdWatermark.style.top = '4px';
|
||||
chanMacdWatermark.style.left = '8px';
|
||||
chanMacdWatermark.style.fontSize = '11px';
|
||||
chanMacdWatermark.style.color = '#888';
|
||||
chanMacdWatermark.style.pointerEvents = 'none';
|
||||
chanMacdChartContainer.appendChild(chanMacdWatermark);
|
||||
|
||||
// 成交量位于 ChanMACD 之下
|
||||
volumeChartContainer.style.top = '70%';
|
||||
volumeChartContainer.style.height = '17.5%';
|
||||
|
||||
// ATR 位于最底部
|
||||
atrChartContainer.style.top = '87.5%';
|
||||
atrChartContainer.style.height = '12.5%';
|
||||
} else {
|
||||
// 不显示MACD时的高度 - 主图、成交量图和ATR图分配
|
||||
mainChartContainer.style.height = '55%'; // 主图占55%
|
||||
volumeChartContainer.style.top = '55%';
|
||||
volumeChartContainer.style.height = '22.5%'; // 成交量图占22.5%
|
||||
|
||||
atrChartContainer.style.top = '77.5%'; // ATR图从77.5%位置开始
|
||||
atrChartContainer.style.height = '22.5%'; // ATR图占22.5%
|
||||
}
|
||||
|
||||
container.appendChild(mainChartContainer);
|
||||
container.appendChild(volumeChartContainer);
|
||||
container.appendChild(atrChartContainer);
|
||||
if (showMacd) {
|
||||
// 只追加新的 ChanMACD 容器
|
||||
container.appendChild(chanMacdChartContainer);
|
||||
}
|
||||
|
||||
// 防止同步过程中的无限循环(实际同步由 bindSyncEvents 负责)
|
||||
|
||||
// 创建统一的图表选项
|
||||
const createChartOptions = (showTimeScale = true, chartType = 'main') => {
|
||||
// 根据图表类型确定高度
|
||||
let chartHeight;
|
||||
if (chartType === 'main') {
|
||||
chartHeight = mainChartContainer.clientHeight;
|
||||
} else if (chartType === 'volume') {
|
||||
chartHeight = volumeChartContainer.clientHeight;
|
||||
} else if (chartType === 'atr') {
|
||||
chartHeight = atrChartContainer.clientHeight;
|
||||
} else if (chartType === 'macd') {
|
||||
chartHeight = macdChartContainer ? macdChartContainer.clientHeight : 0;
|
||||
} else if (chartType === 'chanmacd') {
|
||||
chartHeight = chanMacdChartContainer ? chanMacdChartContainer.clientHeight : 0;
|
||||
} else {
|
||||
chartHeight = mainChartContainer.clientHeight;
|
||||
}
|
||||
|
||||
const baseOptions = {
|
||||
width: mainChartContainer.clientWidth,
|
||||
height: chartHeight,
|
||||
layout: {
|
||||
background: { color: '#ffffff' },
|
||||
textColor: '#333',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#f0f0f0' },
|
||||
horzLines: { color: '#f0f0f0' },
|
||||
},
|
||||
crosshair: {
|
||||
mode: LightweightCharts.CrosshairMode.Normal,
|
||||
// 添加十字线工具提示本地化配置
|
||||
horzLine: {
|
||||
labelVisible: true,
|
||||
},
|
||||
vertLine: {
|
||||
labelVisible: true,
|
||||
// 自定义时间格式化
|
||||
labelFormatter: (time) => {
|
||||
const selectedTimezone = $('#timezone').val();
|
||||
try {
|
||||
const date = new Date(time * 1000);
|
||||
if (symbolConfig.type === 'a_stock') {
|
||||
// A股使用中国时区格式
|
||||
return date.toLocaleString('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
} else {
|
||||
return date.toLocaleString('zh-CN', {
|
||||
timeZone: selectedTimezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('十字线时间格式化错误:', e);
|
||||
return new Date(time * 1000).toLocaleString();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#ddd',
|
||||
scaleMargins: {
|
||||
top: 0.1,
|
||||
bottom: 0.1,
|
||||
},
|
||||
// 为标签留出更多空间,防止遮挡
|
||||
minimumWidth: 80,
|
||||
},
|
||||
// 添加左边距配置
|
||||
leftPriceScale: {
|
||||
visible: false,
|
||||
},
|
||||
// 添加本地化选项,确保所有时间显示都使用选定的时区
|
||||
localization: {
|
||||
timeFormatter: (time) => {
|
||||
const selectedTimezone = $('#timezone').val();
|
||||
try {
|
||||
const date = new Date(time * 1000);
|
||||
if (symbolConfig.type === 'a_stock') {
|
||||
// A股使用中国时区格式
|
||||
return date.toLocaleString('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
} else {
|
||||
return date.toLocaleString('zh-CN', {
|
||||
timeZone: selectedTimezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('全局时间格式化错误:', e);
|
||||
return new Date(time * 1000).toLocaleString();
|
||||
}
|
||||
}
|
||||
},
|
||||
timeScale: {
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
visible: showTimeScale,
|
||||
borderColor: '#ddd',
|
||||
barSpacing: symbolConfig.type === 'a_stock' ? 6 : 10,
|
||||
// 确保所有图表使用相同的边距设置
|
||||
rightOffset: 12,
|
||||
// 移除可能影响拖动的固定边缘设置
|
||||
// fixLeftEdge: true,
|
||||
// fixRightEdge: true,
|
||||
lockVisibleTimeRangeOnResize: true,
|
||||
tickMarkFormatter: (time) => {
|
||||
const selectedTimezone = symbolConfig.type === 'a_stock' ? 'Asia/Shanghai' : $('#timezone').val();
|
||||
try {
|
||||
// 使用完整的配置确保时区正确应用
|
||||
const date = new Date(time * 1000);
|
||||
console.log('格式化时间:', time, '转换为:', date.toISOString(), '时区:', selectedTimezone);
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
timeZone: selectedTimezone,
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('时间格式化错误:', e);
|
||||
// 如果时区格式化失败,返回简单格式
|
||||
return new Date(time * 1000).toLocaleString();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 根据交易对类型调整配置
|
||||
return adjustChartForSymbolType(baseOptions, symbolConfig);
|
||||
};
|
||||
|
||||
// 创建主图表
|
||||
const mainChart = LightweightCharts.createChart(mainChartContainer, createChartOptions(true, 'main'));
|
||||
|
||||
// Cycle Summary 挂到主图左下角(相对 K 线主图 pane,而非整图底边)
|
||||
(function mountWyckoffCycleSummary() {
|
||||
var summaryEl = document.getElementById('wyckoffCycleSummary');
|
||||
if (!summaryEl) {
|
||||
summaryEl = document.createElement('div');
|
||||
summaryEl.id = 'wyckoffCycleSummary';
|
||||
summaryEl.className = 'wyckoff-cycle-summary';
|
||||
summaryEl.setAttribute('aria-live', 'polite');
|
||||
}
|
||||
mainChartContainer.appendChild(summaryEl);
|
||||
if (typeof renderWyckoffCycleSummary === 'function') {
|
||||
try { renderWyckoffCycleSummary(); } catch (e) {}
|
||||
}
|
||||
})();
|
||||
|
||||
// 创建成交量图表 - 只显示底部的时间轴
|
||||
const volumeChart = LightweightCharts.createChart(volumeChartContainer, createChartOptions(false, 'volume'));
|
||||
|
||||
// 创建ATR图表
|
||||
const atrChart = LightweightCharts.createChart(atrChartContainer, createChartOptions(false, 'atr'));
|
||||
|
||||
// 创建MACD图表(如果需要):仅创建新的 ChanMACD 图
|
||||
let macdChart = null;
|
||||
let chanMacdChart = null;
|
||||
if (showMacd) {
|
||||
chanMacdChart = LightweightCharts.createChart(chanMacdChartContainer, createChartOptions(false, 'chanmacd'));
|
||||
}
|
||||
|
||||
// 创建主价格系列并设置数据(支持多种图表类型)
|
||||
(function(){
|
||||
const klineType = ($('#klineType').val() || 'candlestick');
|
||||
// 先清空旧的主系列引用
|
||||
tvWidget.series.candleSeries = null;
|
||||
tvWidget.series.lineSeries = null;
|
||||
tvWidget.series.barSeries = null;
|
||||
tvWidget.series.areaSeries = null;
|
||||
tvWidget.series.baselineSeries = null;
|
||||
tvWidget.series.renkoSeries = null;
|
||||
tvWidget.series.heikinSeries = null;
|
||||
|
||||
if (klineType === 'candlestick') {
|
||||
const series = mainChart.addCandlestickSeries({
|
||||
upColor: '#28a745',
|
||||
downColor: '#dc3545',
|
||||
borderVisible: false,
|
||||
wickUpColor: '#28a745',
|
||||
wickDownColor: '#dc3545',
|
||||
});
|
||||
series.setData(candles);
|
||||
tvWidget.series.candleSeries = series;
|
||||
} else if (klineType === 'renko') {
|
||||
const series = mainChart.addCandlestickSeries({
|
||||
upColor: '#28a745',
|
||||
downColor: '#dc3545',
|
||||
borderVisible: false,
|
||||
wickUpColor: '#28a745',
|
||||
wickDownColor: '#dc3545',
|
||||
});
|
||||
const bricks = buildRenkoFromCandles(candles);
|
||||
series.setData(bricks);
|
||||
tvWidget.series.renkoSeries = series;
|
||||
} else if (klineType === 'heikin') {
|
||||
const series = mainChart.addCandlestickSeries({
|
||||
upColor: '#28a745',
|
||||
downColor: '#dc3545',
|
||||
borderVisible: false,
|
||||
wickUpColor: '#28a745',
|
||||
wickDownColor: '#dc3545',
|
||||
});
|
||||
const hk = buildHeikinFromCandles(candles);
|
||||
series.setData(hk);
|
||||
tvWidget.series.heikinSeries = series;
|
||||
} else if (klineType === 'bar') {
|
||||
const series = mainChart.addBarSeries({
|
||||
upColor: '#28a745',
|
||||
downColor: '#dc3545',
|
||||
thinBars: false
|
||||
});
|
||||
series.setData(candles);
|
||||
tvWidget.series.barSeries = series;
|
||||
} else if (klineType === 'line') {
|
||||
const series = mainChart.addLineSeries({
|
||||
color: '#2962FF',
|
||||
lineWidth: 2,
|
||||
crosshairMarkerVisible: true,
|
||||
lastValueVisible: true,
|
||||
priceLineVisible: true,
|
||||
});
|
||||
const lineData = candles.map(c => ({ time: c.time, value: c.close }));
|
||||
series.setData(lineData);
|
||||
tvWidget.series.lineSeries = series;
|
||||
} else if (klineType === 'area') {
|
||||
const series = mainChart.addAreaSeries({
|
||||
topColor: 'rgba(41, 98, 255, 0.4)',
|
||||
bottomColor: 'rgba(41, 98, 255, 0.0)',
|
||||
lineColor: '#2962FF',
|
||||
lineWidth: 2,
|
||||
});
|
||||
const areaData = candles.map(c => ({ time: c.time, value: c.close }));
|
||||
series.setData(areaData);
|
||||
tvWidget.series.areaSeries = series;
|
||||
} else if (klineType === 'baseline') {
|
||||
const series = mainChart.addBaselineSeries({
|
||||
baseValue: { type: 'price', price: candles.length ? candles[candles.length - 1].close : 0 },
|
||||
topLineColor: '#26a69a',
|
||||
bottomLineColor: '#ef5350',
|
||||
topFillColor1: 'rgba(38, 166, 154, 0.28)',
|
||||
topFillColor2: 'rgba(38, 166, 154, 0.05)',
|
||||
bottomFillColor1: 'rgba(239, 83, 80, 0.28)',
|
||||
bottomFillColor2: 'rgba(239, 83, 80, 0.05)'
|
||||
});
|
||||
const baseData = candles.map(c => ({ time: c.time, value: c.close }));
|
||||
series.setData(baseData);
|
||||
tvWidget.series.baselineSeries = series;
|
||||
} else if (klineType === 'klc') {
|
||||
// KLC显示模式 - 使用蜡烛线显示KLC数据
|
||||
const series = mainChart.addCandlestickSeries({
|
||||
upColor: '#28a745',
|
||||
downColor: '#dc3545',
|
||||
borderVisible: false,
|
||||
wickUpColor: '#28a745',
|
||||
wickDownColor: '#dc3545',
|
||||
});
|
||||
// 使用KLC数据创建蜡烛图
|
||||
const klcCandles = buildKLCFromAnalysis(currentData);
|
||||
series.setData(klcCandles);
|
||||
tvWidget.series.klcSeries = series;
|
||||
}
|
||||
})();
|
||||
ctx.symbolConfig = symbolConfig;
|
||||
ctx.useSubSubPeriod = useSubSubPeriod;
|
||||
ctx.useElementPeriod = useElementPeriod;
|
||||
ctx.klinePeriodLabel = klinePeriodLabel;
|
||||
ctx.candles = candles;
|
||||
ctx.klineDataSource = klineDataSource;
|
||||
ctx.container = container;
|
||||
ctx.showMacd = showMacd;
|
||||
ctx.showOriginalKline = showOriginalKline;
|
||||
ctx.mainChartContainer = mainChartContainer;
|
||||
ctx.volumeChartContainer = volumeChartContainer;
|
||||
ctx.atrChartContainer = atrChartContainer;
|
||||
ctx.macdChartContainer = macdChartContainer;
|
||||
ctx.chanMacdChartContainer = chanMacdChartContainer;
|
||||
ctx.mainChart = mainChart;
|
||||
ctx.volumeChart = volumeChart;
|
||||
ctx.atrChart = atrChart;
|
||||
ctx.macdChart = macdChart;
|
||||
ctx.chanMacdChart = chanMacdChart;
|
||||
ctx.createChartOptions = createChartOptions;
|
||||
}
|
||||
+227
-29
@@ -1,4 +1,46 @@
|
||||
/* chart_view.js — split from chart.js */
|
||||
|
||||
/** 用尾部 N 根合并进已有 K 线(同 timestamp 覆盖,更新则追加) */
|
||||
function mergeKlineTail(existing, incoming) {
|
||||
if (!Array.isArray(incoming) || !incoming.length) {
|
||||
return Array.isArray(existing) ? existing : [];
|
||||
}
|
||||
if (!Array.isArray(existing) || !existing.length) {
|
||||
return incoming.slice();
|
||||
}
|
||||
const out = existing.slice();
|
||||
const barTs = (row) => {
|
||||
if (row && row.timestamp != null && row.timestamp !== '') {
|
||||
const n = Number(row.timestamp);
|
||||
if (!Number.isNaN(n)) return n;
|
||||
}
|
||||
const t = row && row.date != null ? new Date(row.date).getTime() : NaN;
|
||||
return Number.isNaN(t) ? null : t;
|
||||
};
|
||||
for (let i = 0; i < incoming.length; i++) {
|
||||
const row = incoming[i];
|
||||
const ts = barTs(row);
|
||||
if (ts == null) continue;
|
||||
let idx = -1;
|
||||
const scanFrom = Math.max(0, out.length - 8);
|
||||
for (let j = out.length - 1; j >= scanFrom; j--) {
|
||||
if (barTs(out[j]) === ts) {
|
||||
idx = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (idx >= 0) {
|
||||
out[idx] = Object.assign({}, out[idx], row);
|
||||
} else {
|
||||
const lastTs = barTs(out[out.length - 1]);
|
||||
if (lastTs == null || ts > lastTs) {
|
||||
out.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function updateChart(options) {
|
||||
options = options || {};
|
||||
// 只显示旋转加载图标
|
||||
@@ -13,7 +55,7 @@ function updateChart(options) {
|
||||
symbol = $('#astockSymbol').val() || '000001';
|
||||
}
|
||||
|
||||
const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '5m';
|
||||
const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '4h';
|
||||
const timezone = $('#timezone').val() || 'Asia/Shanghai';
|
||||
const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m';
|
||||
const subSubTimeframe = $('#subSubTimeframe').val() || '';
|
||||
@@ -47,9 +89,88 @@ function updateChart(options) {
|
||||
if (options.fromAutoRefresh && window._analyzeXhr && window._analyzeXhr.readyState !== 4) {
|
||||
try { window._analyzeXhr.abort(); } catch (e) {}
|
||||
}
|
||||
|
||||
// 请求发出前冻结视窗(与自动刷新同一套;避免等响应时/setData 后 logical 索引漂移)
|
||||
try {
|
||||
if (tvWidget && tvWidget.mainChart) {
|
||||
window._preserveViewOnRefresh = captureChartViewState(tvWidget.mainChart);
|
||||
const prev = currentData && (
|
||||
($('#subSubPeriodKline').is(':checked') && currentData.sub_sub_kline_data) ||
|
||||
($('#elementPeriodKline').is(':checked') && currentData.element_kline_data) ||
|
||||
currentData.kline_data
|
||||
);
|
||||
window._preserveViewBarCount = Array.isArray(prev) ? prev.length : 0;
|
||||
console.log('📌 刷新前冻结视窗 bars=', window._preserveViewBarCount, window._preserveViewOnRefresh);
|
||||
}
|
||||
} catch (e) {
|
||||
window._preserveViewOnRefresh = null;
|
||||
window._preserveViewBarCount = 0;
|
||||
}
|
||||
|
||||
const requestId = ++lastRequestId;
|
||||
const chartsReady = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart);
|
||||
const hasBaseline = !!(currentData && Array.isArray(currentData.kline_data) && currentData.kline_data.length);
|
||||
const baselineSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || '';
|
||||
// 自动刷新常态:只拉最近 2 根;换币对后基线不一致则禁止尾部合并(否则会叠旧缠论)
|
||||
// fullAnalyze(约每 1 分钟)走全量 analyze 更新缠论
|
||||
const useRecentTail = !!(
|
||||
options.fromAutoRefresh &&
|
||||
!options.fullAnalyze &&
|
||||
chartsReady &&
|
||||
hasBaseline &&
|
||||
baselineSymbol &&
|
||||
baselineSymbol === symbol
|
||||
);
|
||||
|
||||
if (useRecentTail) {
|
||||
console.log('自动刷新 → /api/klines/recent limit=2');
|
||||
window._analyzeXhr = $.ajax({
|
||||
url: '/api/klines/recent',
|
||||
data: {
|
||||
symbol: symbol,
|
||||
timeframe: timeframe,
|
||||
limit: 2,
|
||||
element_timeframe: elementTimeframe || undefined,
|
||||
sub_sub_timeframe: subSubTimeframe || undefined
|
||||
},
|
||||
success: function(partial) {
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
if (requestId !== lastRequestId) return;
|
||||
if (!partial || !Array.isArray(partial.kline_data)) {
|
||||
console.warn('recent 响应无效,回退全量 analyze');
|
||||
updateChart({ incremental: true, reason: 'recent-fallback' });
|
||||
return;
|
||||
}
|
||||
currentData.kline_data = mergeKlineTail(currentData.kline_data, partial.kline_data);
|
||||
if (Array.isArray(partial.element_kline_data)) {
|
||||
currentData.element_kline_data = mergeKlineTail(
|
||||
currentData.element_kline_data, partial.element_kline_data
|
||||
);
|
||||
if (partial.element_timeframe) {
|
||||
currentData.element_timeframe = partial.element_timeframe;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(partial.sub_sub_kline_data)) {
|
||||
currentData.sub_sub_kline_data = mergeKlineTail(
|
||||
currentData.sub_sub_kline_data, partial.sub_sub_kline_data
|
||||
);
|
||||
if (partial.sub_sub_timeframe) {
|
||||
currentData.sub_sub_timeframe = partial.sub_sub_timeframe;
|
||||
}
|
||||
}
|
||||
refreshChart(currentData, { incremental: true, skipTables: true });
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
if (textStatus === 'abort') return;
|
||||
console.warn('recent 失败,回退全量 analyze:', errorThrown);
|
||||
updateChart({ incremental: true, reason: 'recent-error-fallback' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const requestId = ++lastRequestId; // 标记本次请求
|
||||
// 手动 / 首拉:全量 analyze
|
||||
window._analyzeXhr = $.ajax({
|
||||
url: '/api/analyze',
|
||||
data: {
|
||||
@@ -62,8 +183,8 @@ function updateChart(options) {
|
||||
end_time: endTimeMs,
|
||||
elements_only: false,
|
||||
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
|
||||
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0,
|
||||
include_wyckoff: $('#showWyckoff').is(':checked') ? 1 : 0
|
||||
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0
|
||||
// 威科夫随主分析一并返回;开关仅控制绘制,不再传 include_wyckoff
|
||||
},
|
||||
success: function(data) {
|
||||
// 隐藏加载图标
|
||||
@@ -75,18 +196,31 @@ function updateChart(options) {
|
||||
}
|
||||
|
||||
// 保存当前数据
|
||||
const prevSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || '';
|
||||
if (currentData) {
|
||||
// 覆盖前断开旧引用,帮助GC尽快回收
|
||||
delete currentData.original_kline_data;
|
||||
delete currentData.original_macd;
|
||||
}
|
||||
currentData = data;
|
||||
window._lastChartSymbol = symbol;
|
||||
window._lastFullAnalyzeAt = Date.now();
|
||||
if (typeof renderWyckoffCycleSummary === 'function') {
|
||||
renderWyckoffCycleSummary();
|
||||
}
|
||||
|
||||
refreshChart(data, {
|
||||
incremental: options.incremental !== undefined
|
||||
? !!options.incremental
|
||||
: !!options.fromAutoRefresh
|
||||
});
|
||||
// 有图则增量;笔/段/中枢/结构区只在全量 init 绘制
|
||||
// 换币对 / 手动分析 / 结构区:必须全量重建,否则会残留旧币对叠层
|
||||
const ready = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart);
|
||||
const structureZonesOn = $('#showMainStructureZone').is(':checked');
|
||||
const symbolChanged = !!(prevSymbol && prevSymbol !== symbol);
|
||||
let wantIncremental = options.incremental !== undefined
|
||||
? !!options.incremental
|
||||
: (ready || !!options.fromAutoRefresh);
|
||||
if (structureZonesOn || options.fullAnalyze || symbolChanged || options.incremental === false) {
|
||||
wantIncremental = false;
|
||||
}
|
||||
refreshChart(data, { incremental: wantIncremental });
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
// 隐藏加载图标
|
||||
@@ -117,49 +251,113 @@ function captureChartViewState(chart) {
|
||||
};
|
||||
}
|
||||
|
||||
function restoreChartViewState(charts, viewState) {
|
||||
/** 将可见时间窗口限制在真实 K 线范围内,避免 to 落在右侧空白区导致锚到最右 */
|
||||
function clampVisibleRangeToBarTimes(vr, firstTime, lastTime) {
|
||||
if (!vr || vr.from === undefined || vr.to === undefined) return vr;
|
||||
if (firstTime == null || lastTime == null) return vr;
|
||||
const f = Number(firstTime);
|
||||
const l = Number(lastTime);
|
||||
if (!isFinite(f) || !isFinite(l)) return vr;
|
||||
let from = Number(vr.from);
|
||||
let to = Number(vr.to);
|
||||
const span = Math.max(1, to - from);
|
||||
if (to > l) {
|
||||
to = l;
|
||||
from = to - span;
|
||||
}
|
||||
if (from < f) {
|
||||
from = f;
|
||||
to = from + span;
|
||||
}
|
||||
return { from: from, to: to };
|
||||
}
|
||||
|
||||
/** 尾部合并时用 update 代替 setData,避免 LWC 重置滚动位置 */
|
||||
function applySeriesDataTail(series, points, tailOnly) {
|
||||
if (!series || typeof series.setData !== 'function' || !Array.isArray(points) || !points.length) {
|
||||
return;
|
||||
}
|
||||
if (tailOnly && typeof series.update === 'function' && points.length > 2) {
|
||||
points.slice(-4).forEach(function (p) {
|
||||
try { series.update(p); } catch (e) {}
|
||||
});
|
||||
return;
|
||||
}
|
||||
series.setData(points);
|
||||
}
|
||||
|
||||
function restoreChartViewState(charts, viewState, options) {
|
||||
// 全量重建备用:先缩放,再位置;不要在位置前写 rightOffset(会右边缘锚定)
|
||||
if (!viewState || !Array.isArray(charts) || charts.length === 0) return;
|
||||
options = options || {};
|
||||
const preferTime = !!options.preferTime;
|
||||
const validCharts = charts.filter(c => c && c.timeScale);
|
||||
if (validCharts.length === 0) return;
|
||||
const mainChart = validCharts[0];
|
||||
|
||||
validCharts.forEach(c => {
|
||||
try {
|
||||
const optionsPatch = {};
|
||||
if (typeof viewState.barSpacing === 'number') optionsPatch.barSpacing = viewState.barSpacing;
|
||||
if (typeof viewState.rightOffset === 'number') optionsPatch.rightOffset = viewState.rightOffset;
|
||||
if (Object.keys(optionsPatch).length) {
|
||||
c.timeScale().applyOptions(optionsPatch);
|
||||
if (typeof viewState.barSpacing === 'number') {
|
||||
c.timeScale().applyOptions({ barSpacing: viewState.barSpacing });
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
let restored = false;
|
||||
|
||||
// 优先按逻辑范围恢复(对新数据更稳健)
|
||||
if (viewState.logicalRange && viewState.logicalRange.from !== undefined && viewState.logicalRange.to !== undefined) {
|
||||
validCharts.forEach(c => {
|
||||
try {
|
||||
c.timeScale().setVisibleLogicalRange(viewState.logicalRange);
|
||||
const syncFromMain = function () {
|
||||
try {
|
||||
const lrNow = mainChart.timeScale().getVisibleLogicalRange();
|
||||
if (lrNow) {
|
||||
validCharts.forEach(c => {
|
||||
try { c.timeScale().setVisibleLogicalRange(lrNow); } catch (e) {}
|
||||
});
|
||||
restored = true;
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
if (typeof viewState.scrollPosition === 'number') {
|
||||
try {
|
||||
mainChart.timeScale().scrollToPosition(viewState.scrollPosition, false);
|
||||
syncFromMain();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 逻辑范围失败时,回退到时间可见范围
|
||||
if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) {
|
||||
const tryVisibleRange = function () {
|
||||
if (!viewState.visibleRange || viewState.visibleRange.from === undefined || viewState.visibleRange.to === undefined) {
|
||||
return false;
|
||||
}
|
||||
validCharts.forEach(c => {
|
||||
try {
|
||||
c.timeScale().setVisibleRange(viewState.visibleRange);
|
||||
restored = true;
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
return restored;
|
||||
};
|
||||
|
||||
// 最后回退到滚动位置
|
||||
if (!restored && typeof viewState.scrollPosition === 'number') {
|
||||
const tryLogicalRange = function () {
|
||||
if (!viewState.logicalRange || viewState.logicalRange.from === undefined || viewState.logicalRange.to === undefined) {
|
||||
return false;
|
||||
}
|
||||
validCharts.forEach(c => {
|
||||
try { c.timeScale().scrollToPosition(viewState.scrollPosition, false); } catch (e) {}
|
||||
try {
|
||||
c.timeScale().setVisibleLogicalRange(viewState.logicalRange);
|
||||
restored = true;
|
||||
} catch (e) {}
|
||||
});
|
||||
return restored;
|
||||
};
|
||||
|
||||
if (!restored) {
|
||||
if (preferTime) {
|
||||
tryVisibleRange();
|
||||
if (!restored) tryLogicalRange();
|
||||
} else {
|
||||
tryLogicalRange();
|
||||
if (!restored) tryVisibleRange();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 初始化图表
|
||||
|
||||
@@ -85,33 +85,24 @@ $(document).on('change', '#showMainBiZs', function() {
|
||||
$(document).on('change', '#showMainStructureZone', function() {
|
||||
const on = $('#showMainStructureZone').is(':checked');
|
||||
console.log('结构区切换为:', on);
|
||||
// 勾选后才向服务器请求多周期结构区数据;取消勾选仅重绘,不重复拉取
|
||||
// 勾选后才向服务器请求多周期结构区数据;结构区叠层只在全量 init 里绘制,必须 incremental:false
|
||||
if (on) {
|
||||
updateChart();
|
||||
updateChart({ incremental: false });
|
||||
} else {
|
||||
updateChartDisplay();
|
||||
}
|
||||
});
|
||||
|
||||
// 威科夫主开关:勾选才请求;子项仅本地重绘
|
||||
function syncWyckoffSubControls() {
|
||||
const on = $('#showWyckoff').is(':checked');
|
||||
$('#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP').prop('disabled', !on);
|
||||
}
|
||||
$(document).on('change', '#showWyckoff', function() {
|
||||
const on = $('#showWyckoff').is(':checked');
|
||||
syncWyckoffSubControls();
|
||||
console.log('威科夫切换为:', on);
|
||||
if (on) {
|
||||
updateChart();
|
||||
} else {
|
||||
// 区间/阶段/时间/VP:与缠论笔开关一样,本地重绘
|
||||
$(document).on(
|
||||
'change',
|
||||
'#showMainWrRange, #showMainWrPhases, #showMainWrEvents, #showMainWrVP,' +
|
||||
'#showElementWrRange, #showElementWrPhases, #showElementWrEvents, #showElementWrVP,' +
|
||||
'#showSubSubWrRange, #showSubSubWrPhases, #showSubSubWrEvents, #showSubSubWrVP',
|
||||
function() {
|
||||
updateChartDisplay();
|
||||
}
|
||||
});
|
||||
$(document).on('change', '#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP', function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
$(function() { syncWyckoffSubControls(); });
|
||||
);
|
||||
|
||||
// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图
|
||||
$('#showMainTrend').change(function() {
|
||||
|
||||
+298
-25
@@ -1,4 +1,252 @@
|
||||
/* ui.js */
|
||||
|
||||
/** Trading OS 可消费的威科夫 Cycle 摘要(Confirmed + Live 分区;cycles[0]=ACTIVE) */
|
||||
function buildWyckoffCycleSummaryPayload(w, tf) {
|
||||
if (!w) return null;
|
||||
const cycles = (w.cycles && w.cycles.length)
|
||||
? w.cycles
|
||||
: (w.trading_range ? [{
|
||||
id: 0, status: 'ACTIVE', role: 'latest', lifecycle: w.lifecycle || 'UNKNOWN',
|
||||
trading_range: w.trading_range, bias: w.bias,
|
||||
phases: w.phases || [], events: w.events || [],
|
||||
confirmed: { phases: w.phases || [], events: w.events || [] },
|
||||
live: w.live || null,
|
||||
confidence: { overall: null },
|
||||
period: {
|
||||
start_time: w.trading_range.start_time,
|
||||
end_time: w.trading_range.end_time,
|
||||
bars: w.trading_range.bars
|
||||
}
|
||||
}] : []);
|
||||
if (!cycles.length) return null;
|
||||
const active = cycles[0]; // 禁止 cycles[-1]
|
||||
const confirmed = active.confirmed || {
|
||||
phases: active.phases || w.phases || [],
|
||||
events: active.events || w.events || []
|
||||
};
|
||||
const live = active.live || w.live || null;
|
||||
const cPhases = confirmed.phases || [];
|
||||
const cEvents = confirmed.events || [];
|
||||
const lastPhase = cPhases.length ? cPhases[cPhases.length - 1] : null;
|
||||
const lastEvent = cEvents.length ? cEvents[cEvents.length - 1] : null;
|
||||
const tr = active.trading_range || {};
|
||||
const prev = cycles.length > 1 ? cycles[1] : null;
|
||||
const biasLabel = ({
|
||||
accumulation: 'Accumulation',
|
||||
distribution: 'Distribution',
|
||||
unknown: 'Unknown'
|
||||
})[active.bias] || (active.bias || 'Unknown');
|
||||
const liveCand = (live && live.event_candidates && live.event_candidates[0]) || null;
|
||||
const liveConf = live && live.confidence ? live.confidence.overall : null;
|
||||
return {
|
||||
symbol: (typeof currentData !== 'undefined' && currentData && currentData.symbol) || $('#symbol').val() || '',
|
||||
timeframe: (tf || w.timeframe || $('#timeframe').val() || '').toString().toUpperCase(),
|
||||
active: {
|
||||
cycle_id: active.id != null ? active.id : 0,
|
||||
status: active.status || 'ACTIVE',
|
||||
lifecycle: active.lifecycle || (live && live.lifecycle) || 'UNKNOWN',
|
||||
structure: biasLabel,
|
||||
phase_confirmed: lastPhase ? String(lastPhase.phase || '') : null,
|
||||
event_confirmed: lastEvent ? String(lastEvent.type || '') : null,
|
||||
phase_candidate: live ? live.phase_candidate : null,
|
||||
event_candidate: liveCand ? liveCand.type : null,
|
||||
event_candidate_confidence: liveCand ? liveCand.confidence : null,
|
||||
next_expected: live ? live.next_expected : null,
|
||||
range: {
|
||||
low: tr.low,
|
||||
high: tr.high,
|
||||
start_time: (active.period && active.period.start_time) || tr.start_time,
|
||||
end_time: (active.period && active.period.end_time) || tr.end_time,
|
||||
bars: (active.period && active.period.bars) != null ? active.period.bars : tr.bars
|
||||
},
|
||||
confidence_confirmed: (active.confidence && active.confidence.overall != null)
|
||||
? active.confidence.overall
|
||||
: null,
|
||||
confidence_live: liveConf
|
||||
},
|
||||
confirmed_history: cycles.slice(1, 4).map(function(c) {
|
||||
const evs = ((c.confirmed && c.confirmed.events) || c.events || [])
|
||||
.map(function(e) { return e.type; }).filter(Boolean);
|
||||
return {
|
||||
cycle_id: c.id,
|
||||
structure: ({
|
||||
accumulation: 'Accumulation',
|
||||
distribution: 'Distribution',
|
||||
unknown: 'Unknown'
|
||||
})[c.bias] || c.bias,
|
||||
events: evs,
|
||||
lifecycle: c.lifecycle || 'COMPLETED'
|
||||
};
|
||||
}),
|
||||
live: live,
|
||||
cycle_count: cycles.length
|
||||
};
|
||||
}
|
||||
|
||||
function _wrLayerTogglesOn(prefix) {
|
||||
// prefix: Main | Element | SubSub
|
||||
return $('#show' + prefix + 'WrRange').is(':checked')
|
||||
|| $('#show' + prefix + 'WrPhases').is(':checked')
|
||||
|| $('#show' + prefix + 'WrEvents').is(':checked')
|
||||
|| $('#show' + prefix + 'WrVP').is(':checked');
|
||||
}
|
||||
|
||||
/** 面板展示用中文(机器可读 payload 仍保留英文原值) */
|
||||
function _wcsLifecycleZh(v) {
|
||||
return ({
|
||||
UNKNOWN: '未知',
|
||||
FORMING: '形成中',
|
||||
CONFIRMED: '已确认',
|
||||
COMPLETED: '已完成',
|
||||
ACTIVE: '当前'
|
||||
})[v] || v || '未知';
|
||||
}
|
||||
|
||||
function _wcsStructureZh(v) {
|
||||
if (!v) return '—';
|
||||
const key = String(v).toLowerCase();
|
||||
return ({
|
||||
accumulation: '吸筹',
|
||||
distribution: '派发',
|
||||
unknown: '未知'
|
||||
})[key] || ({
|
||||
Accumulation: '吸筹',
|
||||
Distribution: '派发',
|
||||
Unknown: '未知'
|
||||
})[v] || v;
|
||||
}
|
||||
|
||||
function _wcsEventZh(v) {
|
||||
if (v == null || v === '') return '—';
|
||||
return ({
|
||||
Spring: '弹簧',
|
||||
UTAD: '上升后派发',
|
||||
SOS: '强势信号',
|
||||
SOW: '弱势信号',
|
||||
LPS: '最后支撑',
|
||||
LPSY: '最后供应',
|
||||
Test: '回测',
|
||||
PSY: '初步供应',
|
||||
BC: '买气高潮',
|
||||
AR: '自动回落',
|
||||
ST: '二次测试',
|
||||
SC: '卖气高潮'
|
||||
})[v] || v;
|
||||
}
|
||||
|
||||
function _htmlWyckoffSummaryBlock(payload, blockClass) {
|
||||
if (!payload || !payload.active) return '';
|
||||
const a = payload.active;
|
||||
const fmtPx = function(v) {
|
||||
if (v == null || isNaN(Number(v))) return '—';
|
||||
const n = Number(v);
|
||||
return n >= 1000 ? n.toFixed(1) : n.toFixed(4);
|
||||
};
|
||||
const pct = function(v) {
|
||||
if (v == null || isNaN(Number(v))) return '—';
|
||||
return Math.round(Number(v) * 100) + '%';
|
||||
};
|
||||
let html = '<div class="wcs-block ' + (blockClass || '') + '">';
|
||||
html += '<div class="wcs-title">' + (payload.symbol || '') + ' '
|
||||
+ (payload.timeframe || '') + '</div>';
|
||||
html += '<div><span class="wcs-badge">当前 C' + a.cycle_id + '</span> '
|
||||
+ '<span class="wcs-badge" style="background:#fff8c5;color:#9a6700;">'
|
||||
+ _wcsLifecycleZh(a.lifecycle) + '</span></div>';
|
||||
html += '<div class="wcs-active">';
|
||||
html += '<div class="wcs-row"><span class="wcs-k">结构</span><span class="wcs-v">'
|
||||
+ _wcsStructureZh(a.structure) + '</span></div>';
|
||||
html += '<div class="wcs-row"><span class="wcs-k">阶段</span><span class="wcs-v">'
|
||||
+ (a.phase_candidate
|
||||
? ('阶段 ' + a.phase_candidate + '(候选)')
|
||||
: (a.phase_confirmed ? ('阶段 ' + a.phase_confirmed) : '—'))
|
||||
+ '</span></div>';
|
||||
html += '<div class="wcs-row"><span class="wcs-k">事件</span><span class="wcs-v">'
|
||||
+ (a.event_candidate
|
||||
? (_wcsEventZh(a.event_candidate) + '(候选)')
|
||||
: _wcsEventZh(a.event_confirmed))
|
||||
+ '</span></div>';
|
||||
if (a.event_confirmed && a.event_candidate) {
|
||||
html += '<div class="wcs-row"><span class="wcs-k">已确认</span><span class="wcs-v">'
|
||||
+ _wcsEventZh(a.event_confirmed) + '</span></div>';
|
||||
}
|
||||
html += '<div class="wcs-row"><span class="wcs-k">区间</span><span class="wcs-v">'
|
||||
+ fmtPx(a.range && a.range.low) + ' – ' + fmtPx(a.range && a.range.high) + '</span></div>';
|
||||
html += '<div class="wcs-row"><span class="wcs-k">置信度</span><span class="wcs-v">'
|
||||
+ pct(a.confidence_live != null ? a.confidence_live : a.confidence_confirmed) + '</span></div>';
|
||||
if (a.next_expected) {
|
||||
html += '<div class="wcs-row"><span class="wcs-k">下一步</span><span class="wcs-v">'
|
||||
+ _wcsEventZh(a.next_expected) + '</span></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
if (payload.confirmed_history && payload.confirmed_history.length) {
|
||||
html += '<div class="wcs-prev"><div style="margin-bottom:2px;">已确认历史</div>';
|
||||
payload.confirmed_history.forEach(function(h) {
|
||||
const ev = (h.events && h.events.length)
|
||||
? h.events.map(_wcsEventZh).join('、')
|
||||
: '—';
|
||||
html += '<div>C' + h.cycle_id + ' ' + _wcsStructureZh(h.structure) + ' · ' + ev + '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderWyckoffCycleSummary() {
|
||||
const $el = $('#wyckoffCycleSummary');
|
||||
if (!$el.length) return;
|
||||
if (!currentData) {
|
||||
$el.hide().empty();
|
||||
window.wyckoffCycleSummary = null;
|
||||
return;
|
||||
}
|
||||
const layers = [];
|
||||
if (_wrLayerTogglesOn('Main') && currentData.wyckoff) {
|
||||
layers.push({
|
||||
key: 'main',
|
||||
cls: 'wcs-main',
|
||||
payload: buildWyckoffCycleSummaryPayload(
|
||||
currentData.wyckoff,
|
||||
currentData.timeframe || currentData.wyckoff.timeframe || $('#timeframe').val()
|
||||
)
|
||||
});
|
||||
}
|
||||
if (_wrLayerTogglesOn('Element') && currentData.element_wyckoff) {
|
||||
layers.push({
|
||||
key: 'element',
|
||||
cls: 'wcs-element',
|
||||
payload: buildWyckoffCycleSummaryPayload(
|
||||
currentData.element_wyckoff,
|
||||
currentData.element_timeframe || currentData.element_wyckoff.timeframe || $('#elementTimeframe').val()
|
||||
)
|
||||
});
|
||||
}
|
||||
if (_wrLayerTogglesOn('SubSub') && currentData.sub_sub_wyckoff) {
|
||||
layers.push({
|
||||
key: 'sub_sub',
|
||||
cls: 'wcs-subsub',
|
||||
payload: buildWyckoffCycleSummaryPayload(
|
||||
currentData.sub_sub_wyckoff,
|
||||
currentData.sub_sub_timeframe || currentData.sub_sub_wyckoff.timeframe || $('#subSubTimeframe').val()
|
||||
)
|
||||
});
|
||||
}
|
||||
const valid = layers.filter(function(L) { return L.payload && L.payload.active; });
|
||||
if (!valid.length) {
|
||||
$el.hide().empty();
|
||||
window.wyckoffCycleSummary = null;
|
||||
return;
|
||||
}
|
||||
const bag = {};
|
||||
let html = '';
|
||||
valid.forEach(function(L) {
|
||||
bag[L.key] = L.payload;
|
||||
html += _htmlWyckoffSummaryBlock(L.payload, L.cls);
|
||||
});
|
||||
window.wyckoffCycleSummary = bag;
|
||||
$el.html(html).show();
|
||||
}
|
||||
|
||||
function loadSymbols() {
|
||||
$.get('/api/symbols', function(data) {
|
||||
if (Array.isArray(data)) {
|
||||
@@ -24,14 +272,15 @@ function loadSymbols() {
|
||||
});
|
||||
}
|
||||
|
||||
// 设置默认时间范围
|
||||
// 设置默认时间范围:最近 1 个月
|
||||
function setDefaultTimeRange() {
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
||||
const daysBack = 30;
|
||||
const start = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000));
|
||||
|
||||
// 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm
|
||||
$('#end_time').val(formatDatetimeLocal(now));
|
||||
$('#start_time').val(formatDatetimeLocal(oneDayAgo));
|
||||
$('#start_time').val(formatDatetimeLocal(start));
|
||||
}
|
||||
// 格式化日期为datetime-local输入框格式
|
||||
function formatDatetimeLocal(date) {
|
||||
@@ -244,6 +493,9 @@ $(document).ready(function() {
|
||||
let autoRefreshTimer = null;
|
||||
let nextRefreshTime = null;
|
||||
let autoRefreshTick = 0;
|
||||
/** 自动刷新时,缠论全量重算间隔(毫秒);时间戳见 window._lastFullAnalyzeAt */
|
||||
const AUTO_FULL_ANALYZE_MS = 60 * 1000;
|
||||
|
||||
// 初始化自动刷新功能
|
||||
function initAutoRefresh() {
|
||||
// 监听自动刷新勾选框变化
|
||||
@@ -270,10 +522,10 @@ function startAutoRefresh() {
|
||||
stopAutoRefresh();
|
||||
|
||||
// 获取刷新频率(分钟)
|
||||
const interval = parseFloat($('#refreshInterval').val()) || 5;
|
||||
const interval = parseFloat($('#refreshInterval').val()) || (5 / 60);
|
||||
const intervalMs = interval * 60 * 1000;
|
||||
|
||||
console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒)`);
|
||||
console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒);缠论全量每 ${AUTO_FULL_ANALYZE_MS / 1000}s`);
|
||||
|
||||
// 计算下次刷新时间
|
||||
nextRefreshTime = new Date(Date.now() + intervalMs);
|
||||
@@ -282,16 +534,27 @@ function startAutoRefresh() {
|
||||
// 启动定时器
|
||||
autoRefreshTick = 0;
|
||||
autoRefreshTimer = setInterval(function() {
|
||||
// 更新结束时间为当前时间
|
||||
// 更新结束时间显示(仅 UI)
|
||||
updateEndTimeToNow();
|
||||
|
||||
// 多数周期增量更新;每隔若干次全量重建以刷新笔/段/中枢(dispose 已防泄漏)
|
||||
autoRefreshTick += 1;
|
||||
const fullRebuild = (autoRefreshTick % 6) === 0;
|
||||
updateChart({
|
||||
fromAutoRefresh: true,
|
||||
incremental: !fullRebuild
|
||||
});
|
||||
const now = Date.now();
|
||||
const lastFull = window._lastFullAnalyzeAt || 0;
|
||||
const needFullAnalyze = !lastFull || (now - lastFull >= AUTO_FULL_ANALYZE_MS);
|
||||
// 常态:/api/klines/recent 合并尾部 K;满 1 分钟:全量 /api/analyze 刷新缠论
|
||||
if (needFullAnalyze) {
|
||||
console.log('自动刷新 → 全量缠论 analyze(距上次', lastFull ? Math.round((now - lastFull) / 1000) + 's' : '首次', ')');
|
||||
updateChart({
|
||||
fromAutoRefresh: true,
|
||||
fullAnalyze: true,
|
||||
incremental: true
|
||||
});
|
||||
} else {
|
||||
updateChart({
|
||||
fromAutoRefresh: true,
|
||||
incremental: true
|
||||
});
|
||||
}
|
||||
|
||||
// 更新下次刷新时间
|
||||
nextRefreshTime = new Date(Date.now() + intervalMs);
|
||||
@@ -535,15 +798,26 @@ function refreshChart(data, options) {
|
||||
// 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts
|
||||
if (preferIncremental && chartsReady) {
|
||||
try {
|
||||
if (tvWidget.mainChart) {
|
||||
// 数据到达后再冻结视窗(比请求发出时更准;避免用到过期 scroll)
|
||||
if (tvWidget.mainChart && typeof captureChartViewState === 'function') {
|
||||
try {
|
||||
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||
window._preserveViewOnRefresh = captureChartViewState(tvWidget.mainChart);
|
||||
const prev = currentData && (
|
||||
($('#subSubPeriodKline').is(':checked') && currentData.sub_sub_kline_data) ||
|
||||
($('#elementPeriodKline').is(':checked') && currentData.element_kline_data) ||
|
||||
currentData.kline_data
|
||||
);
|
||||
window._preserveViewBarCount = Array.isArray(prev) ? prev.length : 0;
|
||||
} catch (e) {
|
||||
window._pendingRestoreView = null;
|
||||
window._preserveViewOnRefresh = null;
|
||||
window._preserveViewBarCount = 0;
|
||||
}
|
||||
}
|
||||
updateTradingViewData();
|
||||
updateTables(data);
|
||||
updateTradingViewData({ tailOnly: !!options.skipTables });
|
||||
// recent-tail 刷新结构未变,跳过表格重绘以提速
|
||||
if (!options.skipTables) {
|
||||
updateTables(data);
|
||||
}
|
||||
if (currentData && currentData.ema52_dict) {
|
||||
updateEMA52Display(currentData);
|
||||
}
|
||||
@@ -555,7 +829,7 @@ function refreshChart(data, options) {
|
||||
|
||||
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
|
||||
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
|
||||
if (tvWidget && tvWidget.mainChart) {
|
||||
if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) {
|
||||
try {
|
||||
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||
console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView));
|
||||
@@ -563,6 +837,8 @@ function refreshChart(data, options) {
|
||||
console.warn('保存图表视图失败:', e);
|
||||
window._pendingRestoreView = null;
|
||||
}
|
||||
} else if (window._pendingRestoreView) {
|
||||
console.log('📌 使用已保存图表视图:', JSON.stringify(window._pendingRestoreView));
|
||||
}
|
||||
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
@@ -596,14 +872,14 @@ $('#showElementMacdDiv').change(function() {
|
||||
refreshChartOnly();
|
||||
});
|
||||
|
||||
// 绑定分型类型显示开关
|
||||
// 绑定分型类型显示开关(与笔一致:全量重建,避免增量路径标记未对齐)
|
||||
$('#showKlcFxType').change(function() {
|
||||
refreshChartOnly();
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 绑定小周期分型显示开关
|
||||
$('#showElementKlcFxType').change(function() {
|
||||
refreshChart(currentData);
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
|
||||
@@ -616,10 +892,7 @@ $('#showElementBollinger').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 绑定K线周期切换
|
||||
$('input[name="klinePeriod"]').change(function() {
|
||||
refreshChart(currentData);
|
||||
});
|
||||
// K线周期切换由 macd_ui.js 统一走 updateChartDisplay(勿再绑 refreshChart,会重复且易漏对齐)
|
||||
|
||||
// 绑定主图U显示开关
|
||||
$('#toggleUOnMain').change(function() {
|
||||
|
||||
+21
-5
@@ -4,6 +4,16 @@ window.App.Charts = (function() {
|
||||
// 依赖 Indicators
|
||||
const Indicators = (window.App && window.App.Indicators) || {};
|
||||
|
||||
function sanitizeLinePoints(points) {
|
||||
if (!Array.isArray(points)) return [];
|
||||
return points.filter(function (p) {
|
||||
return p && p.time != null && p.value != null &&
|
||||
isFinite(Number(p.time)) && isFinite(Number(p.value));
|
||||
}).map(function (p) {
|
||||
return { time: Math.floor(Number(p.time)), value: Number(p.value) };
|
||||
});
|
||||
}
|
||||
|
||||
function addMovingAveragesToChart(candleData) {
|
||||
if (!window.tvWidget || !tvWidget.mainChart || !candleData || candleData.length === 0) return;
|
||||
if (!window.movingAverages) return;
|
||||
@@ -21,6 +31,8 @@ window.App.Charts = (function() {
|
||||
try {
|
||||
const maData = Indicators.calculateMA(candleData, maConfig.type, maConfig.length, maConfig.source);
|
||||
const smoothedData = maConfig.smoothType !== 'none' ? (window.applySmoothToMA ? window.applySmoothToMA(maData, maConfig.smoothType, maConfig.smoothLength) : maData) : maData;
|
||||
const cleanData = sanitizeLinePoints(smoothedData);
|
||||
if (!cleanData.length) return;
|
||||
const maSeries = tvWidget.mainChart.addLineSeries({
|
||||
color: maConfig.color,
|
||||
lineWidth: maConfig.lineWidth || 2,
|
||||
@@ -30,8 +42,8 @@ window.App.Charts = (function() {
|
||||
priceLineVisible: false,
|
||||
crosshairMarkerVisible: true,
|
||||
});
|
||||
maSeries.setData(smoothedData);
|
||||
maConfig.data = smoothedData;
|
||||
maSeries.setData(cleanData);
|
||||
maConfig.data = cleanData;
|
||||
tvWidget.series.maSeries.push(maSeries);
|
||||
} catch(e) {}
|
||||
});
|
||||
@@ -51,12 +63,16 @@ window.App.Charts = (function() {
|
||||
if (!bbConfig.visible) return;
|
||||
try {
|
||||
const bbData = Indicators.calculateBB(candleData, bbConfig.length, bbConfig.upperMultiplier, bbConfig.lowerMultiplier, bbConfig.source);
|
||||
const upper = sanitizeLinePoints(bbData.map(item => ({ time: item.time, value: item.upper })));
|
||||
const middle = sanitizeLinePoints(bbData.map(item => ({ time: item.time, value: item.middle })));
|
||||
const lower = sanitizeLinePoints(bbData.map(item => ({ time: item.time, value: item.lower })));
|
||||
if (!upper.length || !middle.length || !lower.length) return;
|
||||
const upperSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.upperColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true });
|
||||
const middleSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.middleColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true });
|
||||
const lowerSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.lowerColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true });
|
||||
upperSeries.setData(bbData.map(item => ({ time: item.time, value: item.upper })));
|
||||
middleSeries.setData(bbData.map(item => ({ time: item.time, value: item.middle })));
|
||||
lowerSeries.setData(bbData.map(item => ({ time: item.time, value: item.lower })));
|
||||
upperSeries.setData(upper);
|
||||
middleSeries.setData(middle);
|
||||
lowerSeries.setData(lower);
|
||||
bbConfig.data = bbData;
|
||||
tvWidget.series.bbSeries.push(upperSeries, middleSeries, lowerSeries);
|
||||
} catch(e) {}
|
||||
|
||||
Reference in New Issue
Block a user