refactor: 缠论引擎包化与 Web 分层(ECR-001)

将根目录引擎迁入 chanlun/ 并保留兼容 shim;拆分 TF_DF 与 web 服务;
前端模块化;strategies 改用 chanlun 导入;补充 ESS 文档与 golden 回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 18:48:20 +08:00
co-authored by Cursor
parent e2e45bc1bc
commit 74dec4e50b
160 changed files with 25576 additions and 23104 deletions
+306
View File
@@ -0,0 +1,306 @@
/**
* 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,
}
})()