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

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

320 lines
10 KiB
JavaScript
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.
/**
* TradingView Datafeed — 对接 Data Provider 微服务
*
* REST: https://provider.jackyu66.com
* WS: wss://jackyu66.com/ws (可通过 window.DATA_SERVICE_* 或 URL 参数覆盖)
*
* 实时:subscribeBars → 收到 kline 后调用 onTick(由 Charting Library 增量更新,不重置缩放)
*/
var ChanTVDatafeed = (function () {
'use strict'
function getParam(name) {
try {
var m = (new RegExp('[?&]' + name + '=([^&]*)')).exec(location.search)
return m ? decodeURIComponent(m[1]) : ''
} catch (e) {
return ''
}
}
// REST 与 WS 可分离(nginx 反代)
var DATA_HOST = (window.DATA_SERVICE_URL || getParam('data_host') || 'https://provider.jackyu66.com').replace(/\/$/, '')
var WS_URL = (window.DATA_SERVICE_WS_URL || getParam('ws_url') || 'wss://jackyu66.com/ws').replace(/\/$/, '')
// ---- resolution <-> timeframe 转换 ----
var RES_TO_TF = {
'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',
}
function resToTf(resolution) {
var r = String(resolution)
return RES_TO_TF[r] || r
}
// ---- WebSocket 管理 ----
var ws = null
var wsReconnectTimer = null
var wsSubs = {} // listenerGuid -> { symbol, tf, onTick, lastTickTime }
function wsConnect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
try {
ws = new WebSocket(WS_URL)
} catch (e) {
console.warn('[TV Datafeed] WS 连接失败', e)
scheduleReconnect()
return
}
ws.onopen = function () {
console.log('[TV Datafeed] WS 已连接', WS_URL)
// 重新订阅
Object.keys(wsSubs).forEach(function (guid) {
var sub = wsSubs[guid]
sendWS({ action: 'subscribe', symbol: sub.symbol, timeframe: sub.tf })
})
}
ws.onmessage = function (evt) {
try {
var msg = JSON.parse(evt.data)
var bars = msg.data || msg.bars // data_provider 用 'data' 字段
// 历史快照交给 getBars;实时只走 kline → onTick,避免冲掉缩放
if (msg.type === 'snapshot' || msg.type === 'subscribed') return
if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) {
var sorted = bars.slice().sort(function (a, b) { return (a.timestamp || 0) - (b.timestamp || 0) })
var latest = sorted[sorted.length - 1]
if (!latest || latest.timestamp == null) return
Object.keys(wsSubs).forEach(function (guid) {
var sub = wsSubs[guid]
if (sub.symbol === msg.symbol && sub.tf === msg.timeframe) {
// 允许同 timestamp 更新未收盘棒(用 < 而不是 <=
if (sub.lastTickTime != null && latest.timestamp < sub.lastTickTime) return
var tick = {
time: latest.timestamp,
open: latest.open,
high: latest.high,
low: latest.low,
close: latest.close,
volume: latest.volume,
}
try {
sub.onTick(tick)
sub.lastTickTime = latest.timestamp
} catch (e) { /* ignore */ }
// 通知页面:更新缠论缓存(K 线由 TV onTick 处理,不重置缩放)
try {
if (window.ChanTvRealtime && typeof window.ChanTvRealtime.onBar === 'function') {
window.ChanTvRealtime.onBar(msg.symbol, msg.timeframe, latest)
}
} catch (e2) { /* ignore */ }
}
})
}
} catch (e) {
// ignore parse errors
}
}
ws.onclose = function () {
console.log('[TV Datafeed] WS 断开')
ws = null
scheduleReconnect()
}
ws.onerror = function () {
// onclose 会跟着触发
}
}
function scheduleReconnect() {
if (wsReconnectTimer) return
wsReconnectTimer = setTimeout(function () {
wsReconnectTimer = null
wsConnect()
}, 3000)
}
function sendWS(data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data))
}
}
// ---- Datafeed API ----
/**
* 主配置:返回支持的 resolutions、exchanges 等
*/
function onReady(callback) {
// 使用固定 resolutions(避免 /timeframes 502 阻塞初始化)
var supported = ['1', '5', '15', '30', '60', '120', '240', 'D', 'W']
console.log('[TV Datafeed] onReady — supported_resolutions:', supported)
setTimeout(function () {
callback({
supported_resolutions: supported,
supports_marks: false,
supports_timescale_marks: false,
supports_time: true,
exchanges: [{ value: 'BINANCE', name: 'Binance', desc: 'Binance Futures' }],
symbols_types: [{ name: 'Crypto', value: 'crypto' }],
})
}, 0)
}
/**
* 解析 symbol'BINANCE:BTC/USDT:USDT' → 分离 exchange 和 symbol
*/
function resolveSymbol(symbolName, onResolve, onError) {
var name = String(symbolName)
var exchange = 'BINANCE'
var symbol = name
// 解析 EXCHANGE:SYMBOL 格式
// 如果第一段不含 '/',就是交易所名;否则整串就是 symbol
// 例: 'BINANCE:BTC/USDT:USDT' → exchange=BINANCE, sym=BTC/USDT:USDT
// 'BTC/USDT:USDT' → exchange=BINANCE, sym=BTC/USDT:USDT
// 'BTC/USDT' → exchange=BINANCE, sym=BTC/USDT:USDT
var firstColon = name.indexOf(':')
if (firstColon >= 0) {
var prefix = name.substring(0, firstColon)
if (prefix.indexOf('/') === -1) {
// 第一段是交易所名(如 'BINANCE'
exchange = prefix
symbol = name.substring(firstColon + 1)
}
// 否则第一段含 '/'(如 'BTC/USDT'),整串就是 symbol
}
// data_provider 用 BTC/USDT:USDT 格式(需要 :USDT 后缀)
var dpSymbol = symbol
if (dpSymbol.indexOf(':USDT') === -1 && dpSymbol.indexOf('/USDT') >= 0) {
dpSymbol = dpSymbol + ':USDT'
}
console.log('[TV Datafeed] resolveSymbol', name, '→ exchange:', exchange, 'symbol:', symbol, 'dp:', dpSymbol)
// TV 要求异步回调(setTimeout 0
setTimeout(function () {
onResolve({
name: name,
ticker: name,
description: symbol,
exchange: exchange,
type: 'crypto',
session: '24x7',
timezone: 'Asia/Shanghai',
minmov: 1,
pricescale: 100,
has_intraday: true,
has_seconds: false,
has_daily: true,
has_weekly_and_monthly: true,
supported_resolutions: ['1', '5', '15', '30', '60', '120', '240', 'D', 'W'],
intraday_multipliers: ['1', '5', '15', '30', '60', '120', '240'],
volume_precision: 2,
_dpSymbol: dpSymbol,
})
}, 0)
}
/**
* 获取历史 bars
*/
function getBars(symbolInfo, resolution, periodParams, onResult, onError) {
var tf = resToTf(resolution)
var symbol = symbolInfo._dpSymbol || symbolInfo.ticker.split(':').slice(1).join(':')
// 确保 symbol 是 data_provider 格式
if (symbol.indexOf(':USDT') === -1 && symbol.indexOf('/USDT') >= 0) {
symbol = symbol + ':USDT'
}
var params = 'symbol=' + encodeURIComponent(symbol) + '&tf=' + encodeURIComponent(tf)
// periodParams.from / to 是秒,data_provider 需要毫秒
if (periodParams.from) {
params += '&start=' + (periodParams.from * 1000)
}
if (periodParams.to) {
params += '&end=' + (periodParams.to * 1000)
}
if (periodParams.firstDataRequest) {
// 首次请求多取一些数据供缠论计算
params += '&limit=1000'
}
var url = DATA_HOST + '/api/candles?' + params
console.log('[TV Datafeed] getBars', symbol, tf, '→', url)
fetch(url)
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status)
return r.json()
})
.then(function (data) {
console.log('[TV Datafeed] getBars 返回', data.length, '条')
if (!Array.isArray(data) || data.length === 0) {
onResult([], { noData: true })
return
}
// 按时间升序排列并去重,避免跨请求重叠导致时间顺序冲突
var seen = {}
var bars = []
data.forEach(function (d) {
if (!seen[d.timestamp]) {
seen[d.timestamp] = true
bars.push({
time: d.timestamp, // ms
open: d.open,
high: d.high,
low: d.low,
close: d.close,
volume: d.volume,
})
}
})
bars.sort(function (a, b) { return a.time - b.time })
// 传 noData: false 表示还有更多历史数据
onResult(bars, { noData: false })
})
.catch(function (err) {
console.error('[TV Datafeed] getBars 失败', err)
onError(err.message || '获取数据失败')
})
}
/**
* 订阅实时数据(通过 WebSocket)
*/
function subscribeBars(symbolInfo, resolution, onTick, listenerGuid) {
var tf = resToTf(resolution)
var symbol = symbolInfo._dpSymbol || symbolInfo.ticker.split(':').slice(1).join(':')
if (symbol.indexOf(':USDT') === -1 && symbol.indexOf('/USDT') >= 0) {
symbol = symbol + ':USDT'
}
wsSubs[listenerGuid] = { symbol: symbol, tf: tf, onTick: onTick }
// 确保 WS 已连接
wsConnect()
// 如果已连接,立即订阅
if (ws && ws.readyState === WebSocket.OPEN) {
sendWS({ action: 'subscribe', symbol: symbol, timeframe: tf })
}
// 否则等 WS onopen 时会重新订阅所有
}
/**
* 取消订阅
*/
function unsubscribeBars(listenerGuid) {
var sub = wsSubs[listenerGuid]
if (sub) {
sendWS({ action: 'unsubscribe', symbol: sub.symbol, timeframe: sub.tf })
delete wsSubs[listenerGuid]
}
}
// ---- 导出 ----
return {
onReady: onReady,
resolveSymbol: resolveSymbol,
getBars: getBars,
subscribeBars: subscribeBars,
unsubscribeBars: unsubscribeBars,
}
})()