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