添加tradingview advanced chart lib和实现chan_tv网页

This commit is contained in:
jackyu66git
2026-05-14 14:16:06 +08:00
parent ca2cf86138
commit 050ebeb849
1942 changed files with 43262 additions and 3 deletions
+851
View File
@@ -0,0 +1,851 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<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></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 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 src="/static/js/chan_engine.js?v=3"></script>
<script src="/static/js/tv_datafeed.js?v=5"></script>
<script src="/static/js/chan_indicator.js?v=9"></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
}
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 // 标记是否有待处理的计算
var computeTimeout = null // 防抖,避免 WS 洪水
// ---- 工具: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')
}
// ---- 初始化 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 重新订阅新周期
if (window._resubscribeChanWS) window._resubscribeChanWS()
} catch (e) {
console.warn('setSymbol failed', e)
}
return
}
widget = new TradingView.widget({
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: '#f8f9fa',
client_id: 'chan-core',
user_id: 'local',
custom_indicators_getter: function () {
return Promise.resolve([makeChanIndicator()])
},
disabled_features: [
'header_compare',
'header_saveload',
'study_templates',
'create_volume_indicator_by_default',
'save_chart_properties_to_local_storage',
],
enabled_features: [
'hide_left_toolbar_by_default',
'items_favoriting',
],
favorites: {
intervals: ['1', '5', '15', '30', '60', '240', 'D'],
chartTypes: ['Candles'],
},
studies_overrides: {
'macd.macd.display': 3,
'macd.signal.display': 3,
'macd.histogram.display': 3,
},
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',
},
})
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)
}
} catch (e) {
console.warn('resize MACD pane 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)
}
})
} 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();
// ---- WebSocket 实时 K 线 → 增量缠论更新 ----
(function () {
var ws = null
var reconnectTimer = null
var pendingBars = [] // 收集待处理的新 bar
var lastWsMsgTime = 0 // 上次收到消息的时间(心跳检测)
var heartbeatTimer = null // 心跳超时检测
var backfillTimer = 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
}
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
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,
})
added++
}
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)
}
}
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>
</body>
</html>