fix: 修复主站自动刷新内存泄漏,并完善 chan_tv 图表体验

主站重建前完整 dispose、去掉重复 sync 监听,自动刷新默认增量更新;顺带消除首屏重复 analyze、复用 ChanMACD,以及全版 TV 指标/未完成中枢/布局本地缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-06 16:09:48 +08:00
co-authored by Cursor
parent 6b0f3b5837
commit 9f1e7361b6
14 changed files with 457 additions and 669 deletions
+170 -314
View File
@@ -119,7 +119,7 @@
<!-- 控制栏 -->
<div class="toolbar">
<span class="logo"><span></span></span>
<span class="logo"><span></span> <small style="font-weight:500;color:#888;font-size:12px;">全版</small></span>
<input id="symbol-input" type="text" value="BTC/USDT:USDT" title="交易对">
<select id="tf-select" title="周期">
<option value="1m">1m</option>
@@ -135,6 +135,7 @@
</select>
<button id="btn-reload" class="btn btn-reload" title="刷新数据">↻ 刷新</button>
<button id="btn-theme" class="btn btn-theme" title="切换主题">🌙</button>
<span style="font-size:12px;color:#666;white-space:nowrap;">指标会自动存到本机,刷新后恢复</span>
<span class="status" id="status-bar">
<span class="dot yellow"></span> 初始化...
</span>
@@ -147,26 +148,28 @@
<!-- TV charting library -->
<script src="/charting_library/charting_library.js"></script>
<!-- 自定义模块 -->
<script>
window.DATA_SERVICE_URL = "{{ data_service_url }}";
window.DATA_SERVICE_WS_URL = "{{ data_service_ws_url }}";
</script>
<script src="/static/js/app/api_client.js?v=1"></script>
<script src="/static/js/app/chan_engine.js?v=4"></script>
<script src="/static/js/app/datafeed.js?v=6"></script>
<script src="/static/js/app/chan_indicator.js?v=10"></script>
<script src="/static/js/app/chan_engine.js?v=5"></script>
<script src="/static/js/app/datafeed.js?v=8"></script>
<script src="/static/js/app/chan_indicator.js?v=11"></script>
<script>
(function () {
'use strict'
// ---- 配置 ----
var DATA_HOST = 'http://103.179.242.166'
// WebSocket URL
var WS_URL = DATA_HOST.replace(/^http/, 'ws') + '/ws'
// ---- 读取 URL params ----
function getParam(name, def) {
var m = (new RegExp('[?&]' + name + '=([^&]*)')).exec(location.search)
return m ? decodeURIComponent(m[1]) : def
}
// ---- 配置(REST / WS 可分离)----
var DATA_HOST = (window.DATA_SERVICE_URL || getParam('data_host', 'https://provider.jackyu66.com')).replace(/\/$/, '')
var defaultSymbol = getParam('symbol', 'BTC/USDT:USDT')
var defaultTf = getParam('tf', '5m')
@@ -191,7 +194,6 @@
var lastChanKey = ''
var fetchPromise = null // 防止并发请求
var computePending = false // 标记是否有待处理的计算
var computeTimeout = null // 防抖,避免 WS 洪水
// ---- 工具:resolution ↔ timeframe ----
function tfToRes(tf) {
@@ -378,6 +380,36 @@
setStatus('ok')
}
// ---- 本地缓存图表布局(含已开指标)----
var CHART_STATE_KEY = 'chan_tv_chart_state_v1'
function loadSavedChartState() {
try {
var raw = localStorage.getItem(CHART_STATE_KEY)
if (!raw) return null
return JSON.parse(raw)
} catch (e) {
console.warn('[布局] 读取本地缓存失败', e)
return null
}
}
function persistChartState() {
if (!widget || typeof widget.save !== 'function') return
try {
widget.save(function (state) {
try {
localStorage.setItem(CHART_STATE_KEY, JSON.stringify(state))
console.log('[布局] 已缓存到本地(含指标)')
} catch (e) {
console.warn('[布局] 写入 localStorage 失败', e)
}
})
} catch (e) {
console.warn('[布局] save 失败', e)
}
}
// ---- 初始化 TV Widget ----
function initTvWidget() {
var symbol = symbolInput.value.trim()
@@ -394,15 +426,15 @@
computeAndRefreshChan(true)
}
})
// WS 重新订阅新周期
if (window._resubscribeChanWS) window._resubscribeChanWS()
// WS 重新订阅由 datafeed.subscribeBars 自动处理
} catch (e) {
console.warn('setSymbol failed', e)
}
return
}
widget = new TradingView.widget({
var savedState = loadSavedChartState()
var widgetOpts = {
container: chartContainer,
library_path: '/charting_library/',
datafeed: ChanTVDatafeed,
@@ -413,31 +445,65 @@
theme: getTheme(),
timezone: 'Asia/Shanghai',
locale: 'zh',
toolbar_bg: '#f8f9fa',
toolbar_bg: getTheme() === 'dark' ? '#131722' : '#f8f9fa',
client_id: 'chan-core',
user_id: 'local',
auto_save_delay: 3, // 秒;变更后自动触发 onAutoSaveNeeded
// 仅注入缠论;其余全部走 TradingView 内置指标库(不设 studies_access = 不限制)
custom_indicators_getter: function () {
return Promise.resolve([makeChanIndicator()])
},
// 币安式:顶部可点「指标」添加全部内置指标;左侧绘图工具栏
// 注意:不要开 study_templates(无 charts_storage_url 会 404 打断工具栏)
disabled_features: [
'header_compare',
'header_saveload',
'study_templates',
'create_volume_indicator_by_default',
'save_chart_properties_to_local_storage',
'volume_force_overlay', // 关:成交量独立副图,不叠主图
],
enabled_features: [
'hide_left_toolbar_by_default',
'header_widget',
'header_indicators',
'header_fullscreen_button',
'header_chart_type',
'header_resolutions',
'header_settings',
'header_undo_redo',
'header_screenshot',
'left_toolbar',
'control_bar',
'timeframes_toolbar',
'edit_buttons_in_legend',
'context_menus',
'legend_context_menu',
'pane_context_menu',
'scales_context_menu',
'show_object_tree',
'items_favoriting',
'insert_indicator_dialog_shortcut',
'caption_buttons_text_if_possible',
'create_volume_indicator_by_default',
// 不用 volume_force_overlay:成交量单独副图,与主图分开
'display_legend_on_all_charts',
'use_localstorage_for_settings',
],
favorites: {
intervals: ['1', '5', '15', '30', '60', '240', 'D'],
chartTypes: ['Candles'],
intervals: ['1', '3', '5', '15', '30', '60', '240', 'D'],
chartTypes: ['Candles', 'Line', 'Area'],
indicators: [
'Moving Average',
'EMA Cross',
'MACD',
'Relative Strength Index',
'Bollinger Bands',
'Volume',
'Stochastic',
'Average True Range',
'缠论',
],
},
studies_overrides: {
'macd.macd.display': 3,
'macd.signal.display': 3,
'macd.histogram.display': 3,
'volume.volume.color.0': 'rgba(8, 153, 129, 0.4)',
'volume.volume.color.1': 'rgba(239, 68, 68, 0.4)',
},
overrides: {
'paneProperties.background': getTheme() === 'light' ? '#ffffff' : '#131722',
@@ -449,28 +515,54 @@
'mainSeriesProperties.candleStyle.wickUpColor': '#ef4444',
'mainSeriesProperties.candleStyle.wickDownColor': '#089981',
},
})
}
// 有本地缓存则恢复布局(含已开指标);会覆盖构造时的 symbol/interval
if (savedState) {
widgetOpts.saved_data = savedState
console.log('[布局] 从本地恢复上次指标/布局')
}
widget = new TradingView.widget(widgetOpts)
widget.onChartReady(function () {
chart = widget.activeChart()
window._chanChart = chart // for debugging
if (!chart) return
// 创建 MACD
// 缓存恢复后,若工具栏交易对/周期与缓存不一致,切回工具栏选择(保留指标)
try {
chart.createStudy('MACD', false, false)
} catch (e) {
console.warn('createStudy MACD failed', e)
}
// 调整 MACD 副图大小
try {
var panes = chart.getPanes()
if (panes.length >= 2) {
panes[panes.length - 1].setHeight(150)
var curSym = chart.symbol()
var curRes = chart.resolution()
var wantRes = tfToRes(tf)
if (curSym !== symbol || String(curRes) !== String(wantRes)) {
widget.setSymbol(symbol, wantRes, function () {
chart = widget.activeChart()
})
}
} catch (e) {
console.warn('resize MACD pane failed', e)
console.warn('同步工具栏 symbol/tf 失败', e)
}
// 自动保存:加指标/改设置后写入 localStorage
try {
widget.subscribe('onAutoSaveNeeded', function () {
persistChartState()
})
} catch (e) {
console.warn('subscribe onAutoSaveNeeded failed', e)
}
// 兜底:指标增删时也存一份
try {
widget.subscribe('study', function () {
setTimeout(persistChartState, 500)
})
} catch (e) { /* 部分版本无此事件 */ }
// 确保缠论指标挂上(自定义)
try {
ensureAndPokeChanStudy(chart)
} catch (e) {
console.warn('ensure Chan study failed', e)
}
// 监听数据加载完 → 刷新缠论
@@ -479,7 +571,7 @@
// 监听可视范围变化 → 刷新缠论
attachVisibleRangeListener(chart)
// 监听样式变化 → 持久化
// 监听样式变化 → 持久化缠论样式 + 整图布局
try {
widget.subscribe('study_properties_changed', function (entityId) {
var studies = chart.getAllStudies ? chart.getAllStudies() : []
@@ -491,6 +583,7 @@
var sv = api ? api.getStyleValues() : null
if (sv) saveChanStyles(sv)
}
persistChartState()
})
} catch (e) {
console.warn('subscribe study_properties_changed failed', e)
@@ -570,290 +663,53 @@
setStatus('loading');
initTvWidget();
// ---- WebSocket 实时 K 线 → 增量缠论更新 ----
(function () {
var ws = null
var reconnectTimer = null
var pendingBars = [] // 收集待处理的新 bar
var lastWsMsgTime = 0 // 上次收到消息的时间(心跳检测)
var heartbeatTimer = null // 心跳超时检测
var backfillTimer = null // 回补延迟
// ---- 实时:只用 TV datafeed 的 onTick 更新 K(不重置缩放)----
// 缠论缓存由 datafeed 回调同步;新棒出现(收盘)再重算缠论
;(function () {
var chanDebounce = null
var lastTs = null
function getTfMs(tf) {
var map = { '1m':60000, '3m':180000, '5m':300000, '15m':900000, '30m':1800000,
'1h':3600000, '2h':7200000, '4h':14400000, '6h':21600000, '8h':28800000,
'12h':43200000, '1d':86400000, '3d':259200000, '1w':604800000, '1M':2592000000 }
return map[tf] || 300000
}
window.ChanTvRealtime = {
onBar: function (symbol, tf, bar) {
if (!bar || bar.timestamp == null) return
var curSym, curTf
try { curSym = chart && chart.symbol() } catch (e) { curSym = symbolInput.value.trim() }
try { curTf = chart && resToTf(chart.resolution()) } catch (e) { curTf = tfSelect.value }
if (symbol !== curSym || tf !== curTf) return
function startHeartbeat(tf) {
stopHeartbeat()
// 期望每根 bar 至少收到一次数据,超时设为 3 倍周期 + 10 秒
var interval = Math.min(getTfMs(tf) * 3 + 10000, 60000) // 最长 60s
heartbeatTimer = setTimeout(function () {
console.warn('[WS] 心跳超时,重连...')
if (ws) { try { ws.close() } catch (e) { /* ignore */ } }
reconnectWS()
}, interval)
}
function stopHeartbeat() {
if (heartbeatTimer) clearTimeout(heartbeatTimer)
heartbeatTimer = null
}
function touchHeartbeat() {
lastWsMsgTime = Date.now()
var tf
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
startHeartbeat(tf)
}
function wsConnect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
try { ws = new WebSocket(WS_URL) }
catch (e) { return }
ws.onopen = function () {
console.log('[WS] 已连接')
setStatus('ok')
var sym, tf
try { sym = chart.symbol() } catch (e) { sym = symbolInput.value.trim() }
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
var subMsg = JSON.stringify({ action: 'subscribe', symbol: sym, timeframe: tf })
console.log('[WS] 发送订阅', subMsg)
ws.send(subMsg)
touchHeartbeat()
}
ws.onmessage = function (evt) {
touchHeartbeat()
try {
var msg = JSON.parse(evt.data)
var bars = msg.data || msg.bars
if (msg.type === 'subscribed') {
console.log('[WS] 已订阅', msg.symbol, msg.timeframe)
return
}
if (msg.type === 'snapshot' && bars) {
console.log('[WS] 收到快照', bars.length, '根 bar')
// 仅在 REST 数据尚未到达且没有进行中的请求时才用快照初始化
if (cachedOhlcvBars.length === 0 && !fetchPromise) {
for (var i = 0; i < bars.length; i++) {
var b = bars[i]
cachedOhlcvBars.push({
timestamp: b.timestamp, datetime: b.datetime || new Date(b.timestamp).toISOString(),
open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume || 0,
})
}
var dpS, tF
try { dpS = chart.symbol() } catch (e) { dpS = symbolInput.value.trim() }
try { tF = resToTf(chart.resolution()) } catch (e) { tF = tfSelect.value }
doChanCompute(cachedOhlcvBars, dpS, tF)
}
// 回补缺失数据:检查快照与缓存之间的缺口
scheduleBackfill(bars)
return
}
if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) {
var curSym, curTf
try { curSym = chart.symbol() } catch (e) { curSym = symbolInput.value.trim() }
try { curTf = resToTf(chart.resolution()) } catch (e) { curTf = tfSelect.value }
if (msg.symbol !== curSym || msg.timeframe !== curTf) return
for (var i = 0; i < bars.length; i++) {
var bar = bars[i]
var exists = false
for (var j = cachedOhlcvBars.length - 1; j >= Math.max(0, cachedOhlcvBars.length - 100); j--) {
if (cachedOhlcvBars[j].timestamp === bar.timestamp) { exists = true; break }
}
if (exists) continue
// 也检查 pendingBars 去重
var inPending = false
for (var k = 0; k < pendingBars.length; k++) {
if (pendingBars[k].timestamp === bar.timestamp) { inPending = true; break }
}
if (inPending) continue
pendingBars.push({
timestamp: bar.timestamp, datetime: bar.datetime || new Date(bar.timestamp).toISOString(),
open: bar.open, high: bar.high, low: bar.low, close: bar.close, volume: bar.volume || 0,
})
}
if (computeTimeout) clearTimeout(computeTimeout)
computeTimeout = setTimeout(flushPendingBars, 100)
}
} catch (e) { /* ignore */ }
}
ws.onclose = function () {
ws = null
// 断线前立即处理待处理数据
if (pendingBars.length > 0) {
flushPendingBars()
}
scheduleReconnect()
}
ws.onerror = function () { /* onclose fires next */ }
}
function scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer)
// 自适应重连延迟:短周期用短延迟
var tf, delay = 3000
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
if (tf === '1m' || tf === '3m') delay = 1000
else if (tf === '5m' || tf === '15m') delay = 2000
reconnectTimer = setTimeout(reconnectWS, delay)
}
function reconnectWS() {
reconnectTimer = null
stopHeartbeat()
if (ws) { try { ws.close() } catch (e) { /* ignore */ } ws = null }
wsConnect()
}
// ---- 回补缺失数据 ----
function scheduleBackfill(snapshotBars) {
if (backfillTimer) clearTimeout(backfillTimer)
backfillTimer = setTimeout(function () { backfillGaps(snapshotBars) }, 500)
}
function backfillGaps(snapshotBars) {
if (cachedOhlcvBars.length === 0) return
// 按时间排序缓存
cachedOhlcvBars.sort(function (a, b) { return a.timestamp - b.timestamp })
var lastTs = cachedOhlcvBars[cachedOhlcvBars.length - 1].timestamp
var tf
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
var periodMs = getTfMs(tf)
// 检查最后一根 bar 到当前时间的缺口
var now = Date.now()
var expectedBars = Math.floor((now - lastTs) / periodMs) - 1
if (expectedBars <= 1) return // 缺口 <= 1 根无需回补
console.log('[WS] 检测到数据缺口:', expectedBars, '根 bar, 回补中...')
// 从快照中提取缺失的 bar
if (snapshotBars && snapshotBars.length > 0) {
var filled = 0
for (var i = 0; i < snapshotBars.length; i++) {
var b = snapshotBars[i]
if (b.timestamp <= lastTs) continue
// 去重
var dup = false
for (var j = cachedOhlcvBars.length - 1; j >= Math.max(0, cachedOhlcvBars.length - 200); j--) {
if (cachedOhlcvBars[j].timestamp === b.timestamp) { dup = true; break }
}
if (dup) continue
cachedOhlcvBars.push({
timestamp: b.timestamp, datetime: b.datetime || new Date(b.timestamp).toISOString(),
open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume || 0,
})
filled++
}
if (filled > 0) {
console.log('[WS] 从快照回补', filled, '根 bar')
}
}
// 如果快照不够,从 REST API 拉取
if (expectedBars > 10) {
console.log('[WS] 缺口较大,从 REST 回补...')
var dpSymbol
try { dpSymbol = chart.symbol() } catch (e) { dpSymbol = symbolInput.value.trim() }
var start = lastTs + periodMs
var url = DATA_HOST + '/api/candles?symbol=' + encodeURIComponent(dpSymbol) +
'&tf=' + encodeURIComponent(tf) + '&start=' + start + '&end=' + now + '&limit=500'
fetch(url).then(function (r) { return r.json() }).then(function (data) {
if (!Array.isArray(data)) return
var added = 0
for (var i = 0; i < data.length; i++) {
var d = data[i]
if (d.timestamp <= lastTs) continue
var dup = false
for (var j = cachedOhlcvBars.length - 1; j >= Math.max(0, cachedOhlcvBars.length - 200); j--) {
if (cachedOhlcvBars[j].timestamp === d.timestamp) { dup = true; break }
}
if (dup) continue
var closed = false
if (cachedOhlcvBars && cachedOhlcvBars.length) {
var last = cachedOhlcvBars[cachedOhlcvBars.length - 1]
if (bar.timestamp === last.timestamp) {
last.open = bar.open; last.high = bar.high; last.low = bar.low
last.close = bar.close; last.volume = bar.volume || 0
} else if (bar.timestamp > last.timestamp) {
cachedOhlcvBars.push({
timestamp: d.timestamp, datetime: d.datetime || new Date(d.timestamp).toISOString(),
open: d.open, high: d.high, low: d.low, close: d.close, volume: d.volume || 0,
timestamp: bar.timestamp,
datetime: bar.datetime || new Date(bar.timestamp).toISOString(),
open: bar.open, high: bar.high, low: bar.low, close: bar.close,
volume: bar.volume || 0,
})
added++
if (cachedOhlcvBars.length > 5000) {
cachedOhlcvBars = cachedOhlcvBars.slice(cachedOhlcvBars.length - 5000)
}
closed = true
}
if (added > 0) {
cachedOhlcvBars.sort(function (a, b) { return a.timestamp - b.timestamp })
console.log('[WS] REST 回补', added, '根 bar')
doChanCompute(cachedOhlcvBars, dpSymbol, tf)
}
}).catch(function (err) { console.warn('[WS] REST 回补失败', err.message) })
} else if (expectedBars > 1) {
// 小缺口直接触发重算
cachedOhlcvBars.sort(function (a, b) { return a.timestamp - b.timestamp })
var s, t
try { s = chart.symbol() } catch (e) { s = symbolInput.value.trim() }
try { t = resToTf(chart.resolution()) } catch (e) { t = tfSelect.value }
doChanCompute(cachedOhlcvBars, s, t)
}
// 仅在上一根走完时重算缠论(K/MACD 已由 TV 自带更新)
if (closed || (lastTs != null && bar.timestamp > lastTs)) {
if (chanDebounce) clearTimeout(chanDebounce)
chanDebounce = setTimeout(function () {
chanDebounce = null
if (cachedOhlcvBars && cachedOhlcvBars.length) {
doChanCompute(cachedOhlcvBars, curSym, curTf)
}
}, 400)
}
lastTs = bar.timestamp
}
}
function flushPendingBars() {
computeTimeout = null
if (pendingBars.length === 0) return
if (cachedOhlcvBars.length === 0) {
pendingBars = []
return
}
console.log('[WS] 增量更新', pendingBars.length, '根新 bar')
for (var i = 0; i < pendingBars.length; i++) {
cachedOhlcvBars.push(pendingBars[i])
}
pendingBars = []
// 去重并排序
var seen = {}
var deduped = []
for (var i = 0; i < cachedOhlcvBars.length; i++) {
var ts = cachedOhlcvBars[i].timestamp
if (!seen[ts]) { seen[ts] = true; deduped.push(cachedOhlcvBars[i]) }
}
deduped.sort(function (a, b) { return a.timestamp - b.timestamp })
cachedOhlcvBars = deduped
// 限制缓存大小(保留最近数据)
var maxBars = 5000
if (cachedOhlcvBars.length > maxBars) {
cachedOhlcvBars = cachedOhlcvBars.slice(cachedOhlcvBars.length - maxBars)
}
var dpSymbol, tf
try { dpSymbol = chart.symbol() } catch (e) { dpSymbol = null }
try { tf = resToTf(chart.resolution()) } catch (e) { tf = null }
if (!dpSymbol || !tf) return
doChanCompute(cachedOhlcvBars, dpSymbol, tf)
}
// 延迟连接
setTimeout(wsConnect, 2000)
// 暴露给 initTvWidget
window._resubscribeChanWS = function () {
stopHeartbeat()
if (ws) { try { ws.close() } catch (e) { /* ignore */ } ws = null }
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(reconnectWS, 500)
}
})()
})()
</script>
+4 -4
View File
@@ -1264,11 +1264,11 @@
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260806a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260806a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260806a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260806a"></script>
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script>