/* 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 || '4h', timezone: $('#timezone').val() || 'Asia/Shanghai', elementTimeframe: $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m', subSubTimeframe: $('#subSubTimeframe').val() || '', 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(); if (typeof renderWyckoffCycleSummary === 'function') { renderWyckoffCycleSummary(); } 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 }, 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 }, 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(); } }