将根目录引擎迁入 chanlun/ 并保留兼容 shim;拆分 TF_DF 与 web 服务; 前端模块化;strategies 改用 chanlun 导入;补充 ESS 文档与 golden 回归。 Co-authored-by: Cursor <cursoragent@cursor.com>
862 lines
30 KiB
HTML
862 lines
30 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></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/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>
|
||
(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>
|