主站重建前完整 dispose、去掉重复 sync 监听,自动刷新默认增量更新;顺带消除首屏重复 analyze、复用 ChanMACD,以及全版 TV 指标/未完成中枢/布局本地缓存。 Co-authored-by: Cursor <cursoragent@cursor.com>
718 lines
24 KiB
HTML
718 lines
24 KiB
HTML
<!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>
|