Files
Chan/web/templates/chan_tv.html
T

861 lines
29 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></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>