Files
Chan/web/templates/chan_tv.html
T
jackyu66gitandCursor 9f1e7361b6 fix: 修复主站自动刷新内存泄漏,并完善 chan_tv 图表体验
主站重建前完整 dispose、去掉重复 sync 监听,自动刷新默认增量更新;顺带消除首屏重复 analyze、复用 ChanMACD,以及全版 TV 指标/未完成中枢/布局本地缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 16:09:48 +08:00

718 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-LVVXH3TL04"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-LVVXH3TL04');
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>缠论 Chart — TradingView</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f8f9fa;
color: #333;
overflow: hidden;
height: 100vh;
display: flex;
flex-direction: column;
}
/* 顶部控制栏 */
.toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 14px;
background: #fff;
border-bottom: 1px solid #e0e3eb;
z-index: 10;
flex-shrink: 0;
min-height: 42px;
}
.toolbar .logo {
font-size: 16px;
font-weight: 700;
color: #1a1a2e;
margin-right: 4px;
white-space: nowrap;
}
.toolbar .logo span { color: #2962ff; }
.toolbar input, .toolbar select, .toolbar button {
padding: 5px 10px;
border: 1px solid #d1d5db;
border-radius: 5px;
font-size: 13px;
background: #fff;
color: #333;
outline: none;
}
.toolbar input:focus, .toolbar select:focus {
border-color: #2962ff;
}
.toolbar input {
width: 150px;
font-family: monospace;
}
.toolbar select {
width: 70px;
}
.toolbar .btn {
cursor: pointer;
border: none;
border-radius: 5px;
padding: 5px 12px;
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 4px;
transition: background 0.15s;
}
.btn-theme {
background: #f0f3fa; color: #333;
}
.btn-theme:hover { background: #e0e4f0; }
.btn-reload {
background: #2962ff; color: #fff;
}
.btn-reload:hover { background: #1e4bd8; }
.status {
font-size: 11px;
color: #888;
margin-left: auto;
white-space: nowrap;
}
.status .dot {
display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 4px;
}
.dot.green { background: #22c55e; }
.dot.yellow { background: #f59e0b; }
.dot.red { background: #ef4444; }
/* 图表容器 */
#tv_chart_container {
flex: 1;
min-height: 0;
position: relative;
}
/* 响应式 */
@media (max-width: 600px) {
.toolbar input { width: 100px; }
.toolbar { gap: 6px; padding: 4px 8px; }
.toolbar .logo { font-size: 14px; }
.status { display: none; }
}
</style>
</head>
<body>
<!-- 控制栏 -->
<div class="toolbar">
<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>
<option value="3m">3m</option>
<option value="5m" selected>5m</option>
<option value="15m">15m</option>
<option value="30m">30m</option>
<option value="1h">1h</option>
<option value="2h">2h</option>
<option value="4h">4h</option>
<option value="1d">1d</option>
<option value="1w">1w</option>
</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>
</div>
<!-- TV 图表 -->
<div id="tv_chart_container"></div>
<!-- ========== JS 依赖 ========== -->
<!-- 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=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'
// ---- 读取 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')
// ---- DOM 引用 ----
var symbolInput = document.getElementById('symbol-input')
var tfSelect = document.getElementById('tf-select')
var statusBar = document.getElementById('status-bar')
var btnTheme = document.getElementById('btn-theme')
var chartContainer = document.getElementById('tv_chart_container')
// 初始化值
symbolInput.value = defaultSymbol
tfSelect.value = defaultTf
// ---- 状态 ----
var widget = null
var chart = null
// 当前在 TV 中显示的 bars(供缠论计算使用)
var currentBars = []
var cachedOhlcvBars = [] // 缓存的全量 OHLCV,WS 增量更新
var refreshTimer = null
var lastChanKey = ''
var fetchPromise = null // 防止并发请求
var computePending = false // 标记是否有待处理的计算
// ---- 工具:resolution ↔ timeframe ----
function tfToRes(tf) {
var map = {
'1m': '1', '3m': '3', '5m': '5', '10m': '10', '15m': '15', '30m': '30',
'1h': '60', '2h': '120', '4h': '240', '6h': '360', '8h': '480',
'12h': '720', '1d': 'D', '3d': '3D', '1w': 'W', '1M': 'M',
}
return map[tf] || tf
}
function resToTf(res) {
var map = {
'1': '1m', '3': '3m', '5': '5m', '10': '10m', '15': '15m', '30': '30m',
'60': '1h', '120': '2h', '240': '4h', '360': '6h', '480': '8h',
'720': '12h',
'D': '1d', '1D': '1d',
'3D': '3d',
'W': '1w', '1W': '1w',
'M': '1M', '1M': '1M',
}
return map[String(res)] || String(res).toLowerCase()
}
// ---- 状态栏 ----
function setStatus(state) {
var dot = state === 'ok' ? 'green' : (state === 'loading' ? 'yellow' : 'red')
var text = state === 'ok' ? '就绪' : (state === 'loading' ? '加载中...' : '离线')
statusBar.innerHTML = '<span class="dot ' + dot + '"></span> ' + text
}
// ---- 主题 ----
function getTheme() {
try { return localStorage.getItem('chart-theme') || 'light' }
catch (e) { return 'light' }
}
function setTheme(theme) {
try { localStorage.setItem('chart-theme', theme) }
catch (e) { /* ignore */ }
document.body.style.background = theme === 'light' ? '#f8f9fa' : '#0e1116'
document.body.style.color = theme === 'light' ? '#333' : '#d1d4dc'
btnTheme.innerHTML = theme === 'light' ? '🌙' : '☀'
// 刷新整个页面让 TV + 缠论指标用新主题重建
if (widget && widget.changeTheme) {
widget.changeTheme(theme)
}
}
btnTheme.addEventListener('click', function () {
var newTheme = getTheme() === 'light' ? 'dark' : 'light'
setTheme(newTheme)
})
// 初始主题(但不刷新,TV 创建时会读 localStorage
var initTheme = getTheme()
btnTheme.innerHTML = initTheme === 'light' ? '🌙' : '☀'
// ---- 获取 bars 并计算缠论 ----
var lastComputeKey = ''
function computeAndRefreshChan(force) {
if (!chart) return
// 从 chart 读取当前 symbol/resolution,而非依赖 HTML 下拉框
// 这样 TV 原生工具栏切换周期也能正确触发
var symbol, tf
try { symbol = chart.symbol() } catch (e) { symbol = null }
try { tf = resToTf(chart.resolution()) } catch (e) { tf = null }
if (!symbol || !tf) return
// 同步下拉框(用户可能通过 TV 原生工具栏切换了周期)
if (symbolInput.value !== symbol) symbolInput.value = symbol
if (tfSelect.value !== tf) tfSelect.value = tf
var dpSymbol = symbol
if (dpSymbol.indexOf(':USDT') === -1 && dpSymbol.indexOf('/USDT') >= 0) {
dpSymbol = dpSymbol + ':USDT'
}
var computeKey = dpSymbol + ':' + tf
// 同 key 已在请求中或已完成计算,跳过
if (!force && computeKey === lastComputeKey) {
return
}
lastComputeKey = computeKey
// 如果已有请求进行中,标记 pending 等它完成后再触发一次
if (fetchPromise) {
computePending = true
return
}
setStatus('loading')
computePending = false
var now = Date.now()
// 按周期估算需要的回看天数,保证至少 500 根 bar
var tfMinutes = { '1m':1, '3m':3, '5m':5, '15m':15, '30m':30, '1h':60, '2h':120, '4h':240, '6h':360, '8h':480, '12h':720, '1d':1440, '3d':4320, '1w':10080, '1M':43200 }
var mins = tfMinutes[tf] || 1440
var lookbackDays = Math.max(180, Math.ceil(500 * mins / 1440) + 30)
var start = now - lookbackDays * 24 * 60 * 60 * 1000
var url = DATA_HOST + '/api/candles?symbol=' + encodeURIComponent(dpSymbol) +
'&tf=' + encodeURIComponent(tf) +
'&start=' + start + '&end=' + now + '&limit=2000'
console.log('[缠论] 获取数据', url)
fetchPromise = fetch(url)
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status)
return r.json()
})
.then(function (data) {
fetchPromise = null
if (!Array.isArray(data) || data.length === 0) {
console.warn('[缠论] 数据为空')
setStatus('ok')
if (computePending) { computePending = false; computeAndRefreshChan(true) }
return
}
console.log('[缠论] 获取到', data.length, '条 bar')
// 缓存全量数据
cachedOhlcvBars = data.map(function (d) {
return {
timestamp: d.timestamp,
datetime: d.datetime,
open: d.open,
high: d.high,
low: d.low,
close: d.close,
volume: d.volume,
}
})
// bar index data for buildChanLookup interpolation
currentBars = data.map(function (d) {
return { t: d.timestamp, h: d.high, l: d.low }
})
// 计算缠论并更新图表
doChanCompute(cachedOhlcvBars, dpSymbol, tf)
// 如果在请求期间有新触发,再算一次
if (computePending) {
computePending = false
computeAndRefreshChan(true)
}
})
.catch(function (err) {
fetchPromise = null
console.error('[缠论] 数据获取失败', err)
setStatus('ok')
if (computePending) {
computePending = false
computeAndRefreshChan(true)
}
})
}
// ---- 执行缠论计算并更新图表(共用)----
function doChanCompute(ohlcvBars, dpSymbol, tf) {
// bar index for lookup interpolation
currentBars = ohlcvBars.map(function (d) {
return { t: d.timestamp, h: d.high, l: d.low }
})
// 计算缠论
var chanSlice = computeChan(ohlcvBars)
console.log('[缠论] 笔:', chanSlice.bis.length,
'段:', chanSlice.segs.length,
'中枢:', chanSlice.zs.length,
'BSP:', chanSlice.bsps.length)
// 构建 lookup
var key = dpSymbol + ':' + tf
var lookup = buildChanLookup(chanSlice, currentBars)
commitChanLookup(lookup, key)
lastChanKey = key
// 触发 TV 指标重绘
ensureAndPokeChanStudy(chart)
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()
var tf = tfSelect.value
if (widget) {
// TV widget 已存在:切换 symbol 和 resolution
try {
widget.setSymbol(symbol, tfToRes(tf), function () {
chart = widget.activeChart()
// onDataLoaded 会在新数据加载后自动触发 computeAndRefreshChan
// 此处仅作为兜底:若数据已缓存,onDataLoaded 可能不触发
if (!fetchPromise) {
computeAndRefreshChan(true)
}
})
// WS 重新订阅由 datafeed.subscribeBars 自动处理
} catch (e) {
console.warn('setSymbol failed', e)
}
return
}
var savedState = loadSavedChartState()
var widgetOpts = {
container: chartContainer,
library_path: '/charting_library/',
datafeed: ChanTVDatafeed,
symbol: symbol,
interval: tfToRes(tf),
fullscreen: false,
autosize: true,
theme: getTheme(),
timezone: 'Asia/Shanghai',
locale: 'zh',
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_saveload',
'study_templates',
'volume_force_overlay', // 关:成交量独立副图,不叠主图
],
enabled_features: [
'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', '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: {
'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',
'paneProperties.backgroundType': 'solid',
'mainSeriesProperties.candleStyle.upColor': '#ef4444',
'mainSeriesProperties.candleStyle.downColor': '#089981',
'mainSeriesProperties.candleStyle.borderUpColor': '#ef4444',
'mainSeriesProperties.candleStyle.borderDownColor': '#089981',
'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
// 缓存恢复后,若工具栏交易对/周期与缓存不一致,切回工具栏选择(保留指标)
try {
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('同步工具栏 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)
}
// 监听数据加载完 → 刷新缠论
attachDataLoadedListener(chart)
// 监听可视范围变化 → 刷新缠论
attachVisibleRangeListener(chart)
// 监听样式变化 → 持久化缠论样式 + 整图布局
try {
widget.subscribe('study_properties_changed', function (entityId) {
var studies = chart.getAllStudies ? chart.getAllStudies() : []
var isChan = studies.some(function (s) {
return (s.name === '缠论' || s.name === 'Chan 缠论') && s.id === entityId
})
if (isChan && chart.getStudyById) {
var api = chart.getStudyById(entityId)
var sv = api ? api.getStyleValues() : null
if (sv) saveChanStyles(sv)
}
persistChartState()
})
} catch (e) {
console.warn('subscribe study_properties_changed failed', e)
}
// 初始计算(立即调用,force=true 确保优先执行)
computeAndRefreshChan(true)
})
}
// ---- 事件监听 ----
var dataLoadedSub = null
var rangeSub = null
var dataDebounce = null
var rangeDebounce = null
function attachDataLoadedListener(ch) {
try { if (dataLoadedSub && dataLoadedSub.unsubscribe) dataLoadedSub.unsubscribe() }
catch (e) { /* ignore */ }
dataLoadedSub = null
try {
if (ch.onDataLoaded) {
dataLoadedSub = ch.onDataLoaded().subscribe(null, function () {
if (dataDebounce) clearTimeout(dataDebounce)
dataDebounce = setTimeout(function () {
computeAndRefreshChan()
}, 50)
})
}
} catch (e) {
console.warn('onDataLoaded subscribe failed', e)
}
}
function attachVisibleRangeListener(ch) {
try { if (rangeSub && rangeSub.unsubscribe) rangeSub.unsubscribe() }
catch (e) { /* ignore */ }
rangeSub = null
try {
if (ch.onVisibleRangeChanged) {
rangeSub = ch.onVisibleRangeChanged().subscribe(null, function () {
if (rangeDebounce) clearTimeout(rangeDebounce)
rangeDebounce = setTimeout(function () {
computeAndRefreshChan()
}, 200)
})
}
} catch (e) {
console.warn('onVisibleRangeChanged subscribe failed', e)
}
}
// ---- 按钮事件 ----
document.getElementById('btn-reload').addEventListener('click', function () {
// 清缓存重新拉数据
if (chart) {
try { chart.resetData() } catch (e) { /* ignore */ }
}
setTimeout(function () { computeAndRefreshChan(true) }, 500)
})
// Symbol 输入回车刷新
symbolInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
initTvWidget()
}
})
// Timeframe 切换
tfSelect.addEventListener('change', function () {
initTvWidget()
})
// ---- 启动 ----
setStatus('loading');
initTvWidget();
// ---- 实时:只用 TV datafeed 的 onTick 更新 K(不重置缩放)----
// 缠论缓存由 datafeed 回调同步;新棒出现(收盘)再重算缠论
;(function () {
var chanDebounce = null
var lastTs = null
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
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: 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 (cachedOhlcvBars.length > 5000) {
cachedOhlcvBars = cachedOhlcvBars.slice(cachedOhlcvBars.length - 5000)
}
closed = true
}
}
// 仅在上一根走完时重算缠论(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
}
}
})()
})()
</script>
</body>
</html>