/** * 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, } })()