默认主/次/次次改为 45m/15m/5m、开始时间一周;SD/CD 按 hist 摆位置和箭头;去掉笔线段背驰与第四类勾选。 Co-authored-by: Cursor <cursoragent@cursor.com>
511 lines
19 KiB
JavaScript
511 lines
19 KiB
JavaScript
/* chart_view.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;
|
||
}
|
||
|
||
/** 实时基线是否过旧(仅自动刷新用来决定 recent vs 全量 live) */
|
||
function isLiveBaselineStale(timeframe) {
|
||
if (!currentData || !Array.isArray(currentData.kline_data) || !currentData.kline_data.length) {
|
||
return true;
|
||
}
|
||
const last = currentData.kline_data[currentData.kline_data.length - 1];
|
||
let lastMs;
|
||
if (last.timestamp != null && last.timestamp !== '') {
|
||
lastMs = Number(last.timestamp);
|
||
} else {
|
||
lastMs = new Date(last.date).getTime();
|
||
}
|
||
if (Number.isNaN(lastMs)) return true;
|
||
const tfMs = (window.timeframeToMs && window.timeframeToMs(timeframe)) || (15 * 60 * 1000);
|
||
return (Date.now() - lastMs) > tfMs * 3;
|
||
}
|
||
|
||
/** 兼容旧名 */
|
||
function isChartBaselineStale(timeframe) {
|
||
return isLiveBaselineStale(timeframe);
|
||
}
|
||
|
||
function readChartFormContext() {
|
||
const dataSource = $('#dataSource').val() || 'crypto';
|
||
let symbol;
|
||
if (dataSource === 'crypto') {
|
||
symbol = $('#symbol').val() || 'BTC/USDT:USDT';
|
||
} else {
|
||
symbol = $('#astockSymbol').val() || '000001';
|
||
}
|
||
return {
|
||
dataSource: dataSource,
|
||
symbol: symbol,
|
||
timeframe: $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '45m',
|
||
timezone: $('#timezone').val() || 'Asia/Shanghai',
|
||
elementTimeframe: $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '15m',
|
||
subSubTimeframe: $('#subSubTimeframe').val() || window.DEFAULT_SUB_SUB_TIMEFRAME || '5m',
|
||
startTimeMs: $('#start_time').val() ? new Date($('#start_time').val()).getTime() : null,
|
||
endTimeMs: $('#end_time').val() ? new Date($('#end_time').val()).getTime() : null
|
||
};
|
||
}
|
||
|
||
/** 当前展示用 K 线序列根数(主/小/次次周期与 freeze 逻辑一致) */
|
||
function getActiveKlineBarCount(data) {
|
||
data = data || (typeof currentData !== 'undefined' ? currentData : null);
|
||
if (!data) return 0;
|
||
const series = ($('#subSubPeriodKline').is(':checked') && data.sub_sub_kline_data) ||
|
||
($('#elementPeriodKline').is(':checked') && data.element_kline_data) ||
|
||
data.kline_data;
|
||
return Array.isArray(series) ? series.length : 0;
|
||
}
|
||
|
||
/** 全量 init 前拍快照(周期切换等;不含 _preserveViewOnRefresh) */
|
||
function snapshotPendingChartViewport() {
|
||
if (!tvWidget || !tvWidget.mainChart || typeof captureChartViewState !== 'function') {
|
||
return;
|
||
}
|
||
try {
|
||
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||
window._preserveViewBarCount = getActiveKlineBarCount();
|
||
} catch (e) {
|
||
window._pendingRestoreView = null;
|
||
window._preserveViewBarCount = 0;
|
||
}
|
||
}
|
||
|
||
/** 本地重绘(开关缠论元素 / K 线类型等):先冻结视窗再 init */
|
||
function reinitTradingViewPreservingViewport() {
|
||
snapshotPendingChartViewport();
|
||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||
}
|
||
|
||
/** 全量 init 前确保 _pendingRestoreView 与 bar 数齐全(refreshChart 等路径) */
|
||
function ensurePendingChartViewportBeforeInit() {
|
||
if (window._preserveViewOnRefresh) {
|
||
window._pendingRestoreView = window._preserveViewOnRefresh;
|
||
if (!window._preserveViewBarCount) {
|
||
window._preserveViewBarCount = getActiveKlineBarCount();
|
||
}
|
||
return;
|
||
}
|
||
if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) {
|
||
snapshotPendingChartViewport();
|
||
return;
|
||
}
|
||
if (window._pendingRestoreView && !window._preserveViewBarCount) {
|
||
window._preserveViewBarCount = getActiveKlineBarCount();
|
||
}
|
||
}
|
||
|
||
function freezeChartViewportBeforeRequest() {
|
||
try {
|
||
if (tvWidget && tvWidget.mainChart) {
|
||
const snap = captureChartViewState(tvWidget.mainChart);
|
||
window._preserveViewOnRefresh = snap;
|
||
// 全量 initTradingView 只认 _pendingRestoreView,须与增量冻结同步
|
||
window._pendingRestoreView = snap;
|
||
window._preserveViewBarCount = getActiveKlineBarCount();
|
||
console.log('📌 刷新前冻结视窗 bars=', window._preserveViewBarCount, snap);
|
||
}
|
||
} catch (e) {
|
||
window._preserveViewOnRefresh = null;
|
||
window._pendingRestoreView = null;
|
||
window._preserveViewBarCount = 0;
|
||
}
|
||
}
|
||
|
||
function abortInFlightChartRequest() {
|
||
if (window._analyzeXhr && window._analyzeXhr.readyState !== 4) {
|
||
try { window._analyzeXhr.abort(); } catch (e) {}
|
||
}
|
||
}
|
||
|
||
function applyAnalyzeSuccess(data, symbol, options) {
|
||
options = options || {};
|
||
const prevSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || '';
|
||
if (currentData) {
|
||
delete currentData.original_kline_data;
|
||
delete currentData.original_macd;
|
||
}
|
||
currentData = data;
|
||
window._lastChartSymbol = symbol;
|
||
window._lastFullAnalyzeAt = Date.now();
|
||
|
||
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;
|
||
if (structureZonesOn || options.forceFullRebuild || symbolChanged || options.incremental === false) {
|
||
wantIncremental = false;
|
||
}
|
||
refreshChart(data, { incremental: wantIncremental });
|
||
}
|
||
|
||
/**
|
||
* 手动分析(按钮 / 首屏 / 切换参数)
|
||
* - 严格使用表单 start_time / end_time
|
||
* - 只走 /api/analyze,不做 recent 合并,不改结束时间为现在
|
||
*/
|
||
function analyzeChart(options) {
|
||
options = options || {};
|
||
$('#refreshLoadingSpinner').show();
|
||
|
||
const ctx = readChartFormContext();
|
||
console.log('手动分析:', ctx.symbol, ctx.timeframe, 'range', ctx.startTimeMs, '→', ctx.endTimeMs, options.reason || '');
|
||
|
||
if (!ctx.symbol) {
|
||
console.error('交易对/股票代码不能为空');
|
||
$('#refreshLoadingSpinner').hide();
|
||
return;
|
||
}
|
||
|
||
abortInFlightChartRequest();
|
||
freezeChartViewportBeforeRequest();
|
||
const requestId = ++lastRequestId;
|
||
|
||
window._analyzeXhr = $.ajax({
|
||
url: '/api/analyze',
|
||
data: {
|
||
symbol: ctx.symbol,
|
||
timeframe: ctx.timeframe,
|
||
timezone: ctx.timezone,
|
||
element_timeframe: ctx.elementTimeframe,
|
||
sub_sub_timeframe: ctx.subSubTimeframe || undefined,
|
||
start_time: ctx.startTimeMs,
|
||
end_time: ctx.endTimeMs,
|
||
elements_only: false,
|
||
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
|
||
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0,
|
||
include_wyckoff: 0
|
||
},
|
||
success: function(data) {
|
||
$('#refreshLoadingSpinner').hide();
|
||
if (requestId !== lastRequestId) return;
|
||
applyAnalyzeSuccess(data, ctx.symbol, {
|
||
incremental: options.incremental !== undefined ? options.incremental : false,
|
||
forceFullRebuild: true
|
||
});
|
||
},
|
||
error: function(jqXHR, textStatus, errorThrown) {
|
||
$('#refreshLoadingSpinner').hide();
|
||
if (textStatus === 'abort') return;
|
||
console.error('分析失败:', errorThrown);
|
||
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 自动刷新(仅启用自动刷新时)
|
||
* - 结束时间固定为当前时间(调用方先 updateEndTimeToNow)
|
||
* - mode=recent:/api/klines/recent 增量合并(不重算缠论)
|
||
* - mode=full:/api/analyze 全量 live(start 用表单,end=现在)
|
||
*/
|
||
function autoRefreshChart(options) {
|
||
options = options || {};
|
||
const mode = options.mode === 'full' ? 'full' : 'recent';
|
||
$('#refreshLoadingSpinner').show();
|
||
|
||
const ctx = readChartFormContext();
|
||
console.log('自动刷新:', mode, ctx.symbol, ctx.timeframe, 'end=', ctx.endTimeMs);
|
||
|
||
if (!ctx.symbol) {
|
||
$('#refreshLoadingSpinner').hide();
|
||
return;
|
||
}
|
||
|
||
abortInFlightChartRequest();
|
||
freezeChartViewportBeforeRequest();
|
||
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 || '';
|
||
const canRecent = !!(
|
||
mode === 'recent' &&
|
||
chartsReady &&
|
||
hasBaseline &&
|
||
baselineSymbol &&
|
||
baselineSymbol === ctx.symbol &&
|
||
!isLiveBaselineStale(ctx.timeframe)
|
||
);
|
||
|
||
if (canRecent) {
|
||
console.log('自动刷新 → /api/klines/recent limit=2');
|
||
window._analyzeXhr = $.ajax({
|
||
url: '/api/klines/recent',
|
||
data: {
|
||
symbol: ctx.symbol,
|
||
timeframe: ctx.timeframe,
|
||
limit: 2,
|
||
element_timeframe: ctx.elementTimeframe || undefined,
|
||
sub_sub_timeframe: ctx.subSubTimeframe || undefined
|
||
},
|
||
success: function(partial) {
|
||
$('#refreshLoadingSpinner').hide();
|
||
if (requestId !== lastRequestId) return;
|
||
if (!partial || !Array.isArray(partial.kline_data)) {
|
||
console.warn('recent 无效,改走 live 全量');
|
||
autoRefreshChart({ mode: 'full', 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 失败,改走 live 全量:', errorThrown);
|
||
autoRefreshChart({ mode: 'full', reason: 'recent-error-fallback' });
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
console.log('自动刷新 → /api/analyze (live)');
|
||
window._analyzeXhr = $.ajax({
|
||
url: '/api/analyze',
|
||
data: {
|
||
symbol: ctx.symbol,
|
||
timeframe: ctx.timeframe,
|
||
timezone: ctx.timezone,
|
||
element_timeframe: ctx.elementTimeframe,
|
||
sub_sub_timeframe: ctx.subSubTimeframe || undefined,
|
||
start_time: ctx.startTimeMs,
|
||
end_time: ctx.endTimeMs,
|
||
elements_only: false,
|
||
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
|
||
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0,
|
||
include_wyckoff: 0
|
||
},
|
||
success: function(data) {
|
||
$('#refreshLoadingSpinner').hide();
|
||
if (requestId !== lastRequestId) return;
|
||
applyAnalyzeSuccess(data, ctx.symbol, {
|
||
incremental: options.incremental === true,
|
||
forceFullRebuild: options.forceFullRebuild !== false && options.incremental !== true
|
||
});
|
||
},
|
||
error: function(jqXHR, textStatus, errorThrown) {
|
||
$('#refreshLoadingSpinner').hide();
|
||
if (textStatus === 'abort') return;
|
||
console.error('自动刷新分析失败:', errorThrown);
|
||
}
|
||
});
|
||
}
|
||
|
||
/** 兼容旧调用:手动走 analyzeChart;fromAutoRefresh 转 autoRefreshChart */
|
||
function updateChart(options) {
|
||
options = options || {};
|
||
if (options.fromAutoRefresh) {
|
||
console.warn('updateChart(fromAutoRefresh) 已废弃,请改用 autoRefreshChart');
|
||
autoRefreshChart({
|
||
mode: options.fullAnalyze ? 'full' : 'recent',
|
||
incremental: options.incremental,
|
||
forceFullRebuild: options.incremental === false,
|
||
reason: options.reason
|
||
});
|
||
return;
|
||
}
|
||
analyzeChart(options);
|
||
}
|
||
|
||
function captureChartViewState(chart) {
|
||
if (!chart || !chart.timeScale) return null;
|
||
const ts = chart.timeScale();
|
||
const tsOptions = ts.options ? ts.options() : {};
|
||
return {
|
||
barSpacing: tsOptions.barSpacing,
|
||
rightOffset: tsOptions.rightOffset,
|
||
scrollPosition: ts.scrollPosition ? ts.scrollPosition() : null,
|
||
visibleRange: ts.getVisibleRange ? ts.getVisibleRange() : null,
|
||
logicalRange: ts.getVisibleLogicalRange ? ts.getVisibleLogicalRange() : null
|
||
};
|
||
}
|
||
|
||
/** 将可见时间窗口限制在真实 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) {
|
||
if (!viewState || !Array.isArray(charts) || charts.length === 0) return;
|
||
options = options || {};
|
||
const validCharts = charts.filter(c => c && c.timeScale);
|
||
if (validCharts.length === 0) return;
|
||
const mainChart = validCharts[0];
|
||
const incremental = !!options.incremental;
|
||
|
||
const oldBarCount = options.oldBarCount || window._preserveViewBarCount || 0;
|
||
const newBarCount = options.newBarCount || 0;
|
||
const barDelta = (oldBarCount > 0 && newBarCount > 0) ? (newBarCount - oldBarCount) : 0;
|
||
|
||
const applyLogical = function (lr) {
|
||
if (!lr || lr.from === undefined || lr.to === undefined) return false;
|
||
let from = lr.from;
|
||
let to = lr.to;
|
||
if (newBarCount > 0) {
|
||
const span = Math.max(1, to - from);
|
||
const maxTo = newBarCount - 1 + 8;
|
||
if (to > maxTo) {
|
||
to = maxTo;
|
||
from = to - span;
|
||
}
|
||
if (from < -8) {
|
||
from = -8;
|
||
to = from + span;
|
||
}
|
||
lr = { from: from, to: to };
|
||
}
|
||
let ok = false;
|
||
validCharts.forEach(c => {
|
||
try {
|
||
c.timeScale().setVisibleLogicalRange(lr);
|
||
ok = true;
|
||
} catch (e) {}
|
||
});
|
||
return ok;
|
||
};
|
||
|
||
const applyVisible = function () {
|
||
if (!viewState.visibleRange ||
|
||
viewState.visibleRange.from === undefined ||
|
||
viewState.visibleRange.to === undefined) {
|
||
return false;
|
||
}
|
||
let vr = viewState.visibleRange;
|
||
if (options.firstBarTime != null && options.lastBarTime != null) {
|
||
vr = clampVisibleRangeToBarTimes(vr, options.firstBarTime, options.lastBarTime);
|
||
}
|
||
let ok = false;
|
||
validCharts.forEach(c => {
|
||
try {
|
||
c.timeScale().setVisibleRange(vr);
|
||
ok = true;
|
||
} catch (e) {}
|
||
});
|
||
return ok;
|
||
};
|
||
|
||
const applyScroll = function () {
|
||
if (typeof viewState.scrollPosition !== 'number') return false;
|
||
try {
|
||
const pos = viewState.scrollPosition + barDelta;
|
||
mainChart.timeScale().scrollToPosition(pos, false);
|
||
const lrNow = mainChart.timeScale().getVisibleLogicalRange();
|
||
if (lrNow) {
|
||
validCharts.forEach(c => {
|
||
try { c.timeScale().setVisibleLogicalRange(lrNow); } catch (e) {}
|
||
});
|
||
return true;
|
||
}
|
||
} catch (e) {}
|
||
return false;
|
||
};
|
||
|
||
const restorePosition = function () {
|
||
if (incremental) {
|
||
// 尾部增量:scroll+barDelta 最稳;全量重建勿先 scroll(中间段会锚到最右)
|
||
if (applyScroll()) return true;
|
||
if (applyLogical(viewState.logicalRange)) return true;
|
||
return applyVisible();
|
||
}
|
||
// 全量重建:logical → clamped time → scroll
|
||
if (applyLogical(viewState.logicalRange)) return true;
|
||
if (applyVisible()) return true;
|
||
return applyScroll();
|
||
};
|
||
|
||
restorePosition();
|
||
|
||
// barSpacing 写在位置之后会按右缘重锚,故放最后并再扳一次位置
|
||
if (!options.skipBarSpacing && typeof viewState.barSpacing === 'number') {
|
||
validCharts.forEach(c => {
|
||
try {
|
||
c.timeScale().applyOptions({ barSpacing: viewState.barSpacing });
|
||
} catch (e) {}
|
||
});
|
||
restorePosition();
|
||
}
|
||
}
|