/** * TradingView Datafeed — 对接 Data Provider 微服务 * * 数据源: http://103.179.242.166 * - GET /timeframes → 可用周期 * - GET /api/candles → 历史 OHLCV * - WS /ws → 实时 K 线推送 * * 实现 IDatafeedChartApi 核心接口: * onReady, resolveSymbol, getBars, subscribeBars, unsubscribeBars */ var ChanTVDatafeed = (function () { 'use strict' // 默认 data_provider 地址,可通过 URL param 覆盖 var DATA_HOST = 'http://103.179.242.166' // ---- 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 } var wsUrl = DATA_HOST.replace(/^http/, 'ws') + '/ws' function wsConnect() { if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return try { ws = new WebSocket(wsUrl) } catch (e) { console.warn('[TV Datafeed] WS 连接失败', e) scheduleReconnect() return } ws.onopen = function () { console.log('[TV Datafeed] WS 已连接') // 重新订阅 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' 字段 if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) { // 只推送最新一根 bar,避免历史快照造成时间顺序冲突 // 按时间升序排列取最后一个 var sorted = bars.slice().sort(function (a, b) { return (a.timestamp || 0) - (b.timestamp || 0) }) var latest = sorted[sorted.length - 1] // 广播给所有匹配的 subscriber Object.keys(wsSubs).forEach(function (guid) { var sub = wsSubs[guid] if (sub.symbol === msg.symbol && sub.tf === msg.timeframe) { // 跳过已处理过的时间戳 if (sub.lastTickTime && latest.timestamp <= sub.lastTickTime) return try { sub.onTick({ time: latest.timestamp, open: latest.open, high: latest.high, low: latest.low, close: latest.close, volume: latest.volume, }) sub.lastTickTime = latest.timestamp } catch (e) { /* 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, } })()