fix: 修复主站自动刷新内存泄漏,并完善 chan_tv 图表体验
主站重建前完整 dispose、去掉重复 sync 监听,自动刷新默认增量更新;顺带消除首屏重复 analyze、复用 ChanMACD,以及全版 TV 指标/未完成中枢/布局本地缓存。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+170
-314
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user