Files
Chan/web/static/js/tv_datafeed.js
T

307 lines
9.4 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 微服务
*
* 数据源: 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,
}
})()