前言
最近在做交易所项目里的 K 线图,得些经验,与大家分享。
代码居多,流量预警!!!!
点赞 收藏 不迷路。
技术选型
-
echrats
- 基于 canvas 绘制,种类齐全的可视化图表库。
- 官方地址: https://echarts.baidu.com/
-
highcharts
- 基于 svg 绘制,可定制化内容很多,操纵 dom 更方便。
- 官方地址: https://www.highcharts.com.cn/
-
tradingview
- 基于 canvas 的专业全球化走势图表。
- 官方地址: https://cn.tradingview.com/
-
优缺点
- hightcharts: 前些日子有仔细研究过 hightcharts https://www.fota.com/option。发现 svg 中的 dom 操作,以及定制化内容更好实现,但几乎都需要手动实现的这个特性在开发周期短的压迫下屈服了。上面的这个项目在慢慢摸索下也做了小三个月的样子,但还是很有成就感的。
- echrats: echarts 的官方案例很多,经常在做一些后台管理系统,展现数据时候会用到,方便,易用,使用者也足够多,搜索引擎鸡本能够解决你的任何问题。但对一些在图上划线,等操作,就显得略微疲软。不够能满足需求。
- tradingview: 只要进入官网,就可见其专业性,他完全就是为了专业交易儿打造的,您只需要想里面填充数据就可以了,甚至在一些常用的交易内容上,可以使用 tradingview 自己的数据推送。
-
小记
- 所以,专业的交易图表,就交给专业的库来做吧
- 手动狗头~~~~(∩_∩)
准备工作
-
申请账号(key)
- 在官网注册后会有邮件提示的,一步一步跟着做就可以了,这里就不做赘述了。
-
环境搭建
- 我使用的是自己搭建的 React+webpack4 脚手架,你也可以使用原生 JS,或者你喜欢的任何框架(后面贴出来的代码都是在 React 环境下的)。
-
从官方下载代码库
-
了解 websocket 通讯协议
- 发送请求
- 接收数据
-
大纲
- 这里附上 tradingview 中文开发文档 https://b.aitrade.ga/books/tr…
- 以及 api 示例 http://tradingview.github.io/… (此处需要自备梯子)
- 一位大神的 Demo https://github.com/tenggouwa/…
准备开始吧
创建
-
page |--kLine // k 线内容文件夹 |--|--api // 需要使用的方法 |--|--|--datafees.js // 定义了一些公用方法 |--|--|--dataUpdater.js // 更新时调用的内容 |--|--|--socket.js // websocket 方法 |--|--index.js // 自己代码开发 |--|--index.scss // 样式开发
-
datafees.js 加入如下代码
import React from 'react' import DataUpdater from './dataUpdater' class datafeeds extends React.Component {constructor(self) {super(self) this.self = self this.barsUpdater = new DataUpdater(this) this.defaultConfiguration = this.defaultConfiguration.bind(this) } onReady(callback) {// console.log('=============onReady running') return new Promise((resolve) => {let configuration = this.defaultConfiguration() if (this.self.getConfig) {configuration = Object.assign(this.defaultConfiguration(), this.self.getConfig()) } resolve(configuration) }).then(data => callback(data)) } getBars(symbolInfo, resolution, rangeStartDate, rangeEndDate, onDataCallback) {const onLoadedCallback = (data) => {data && data.length ? onDataCallback(data, { noData: false}) : onDataCallback([], { noData: true}) } this.self.getBars(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback) } resolveSymbol(symbolName, onSymbolResolvedCallback, onResolveErrorCallback) {return new Promise((resolve) => { // reject let symbolInfoName if (this.self.symbolName) {symbolInfoName = this.self.symbolName} let symbolInfo = { name: symbolInfoName, ticker: symbolInfoName, pricescale: 10000, } const {points} = this.props.props const array = points.filter(item => item.name === symbolInfoName) if (array) {symbolInfo.pricescale = 10 ** array[0].pricePrecision } symbolInfo = Object.assign(this.defaultConfiguration(), symbolInfo) resolve(symbolInfo) }).then(data => onSymbolResolvedCallback(data)).catch(err => onResolveErrorCallback(err)) } subscribeBars(symbolInfo, resolution, onRealtimeCallback, subscriberUID, onResetCacheNeededCallback) {this.barsUpdater.subscribeBars(symbolInfo, resolution, onRealtimeCallback, subscriberUID, onResetCacheNeededCallback) } unsubscribeBars(subscriberUID) {this.barsUpdater.unsubscribeBars(subscriberUID) } defaultConfiguration = () => { const object = { session: '24x7', timezone: 'Asia/Shanghai', minmov: 1, minmov2: 0, description: 'www.coinoak.com', pointvalue: 1, volume_precision: 4, hide_side_toolbar: false, fractional: false, supports_search: false, supports_group_request: false, supported_resolutions: ['1', '15', '60', '1D'], supports_marks: false, supports_timescale_marks: false, supports_time: true, has_intraday: true, intraday_multipliers: ['1', '15', '60', '1D'], } return object } } export default datafeeds
-
dataUpdater 加入如下代码
class dataUpdater {constructor(datafeeds) {this.subscribers = {} this.requestsPending = 0 this.historyProvider = datafeeds } subscribeBars(symbolInfonwq, resolutionInfo, newDataCallback, listenerGuid) {this.subscribers[listenerGuid] = { lastBarTime: null, listener: newDataCallback, resolution: resolutionInfo, symbolInfo: symbolInfonwq } } unsubscribeBars(listenerGuid) {delete this.subscribers[listenerGuid] } updateData() {if (this.requestsPending) return this.requestsPending = 0 for (let listenerGuid in this.subscribers) { this.requestsPending++ this.updateDataForSubscriber(listenerGuid).then(() => this.requestsPending--).catch(() => this.requestsPending--) } } updateDataForSubscriber(listenerGuid) {return new Promise(function (resolve, reject) {var subscriptionRecord = this.subscribers[listenerGuid]; var rangeEndTime = parseInt((Date.now() / 1000).toString()); var rangeStartTime = rangeEndTime - this.periodLengthSeconds(subscriptionRecord.resolution, 10); this.historyProvider.getBars(subscriptionRecord.symbolInfo, subscriptionRecord.resolution, rangeStartTime, rangeEndTime, function (bars) {this.onSubscriberDataReceived(listenerGuid, bars); resolve();}, function () {reject(); }); }); } onSubscriberDataReceived(listenerGuid, bars) {if (!this.subscribers.hasOwnProperty(listenerGuid)) return if (!bars.length) return const lastBar = bars[bars.length - 1] const subscriptionRecord = this.subscribers[listenerGuid] if (subscriptionRecord.lastBarTime !== null && lastBar.time < subscriptionRecord.lastBarTime) return const isNewBar = subscriptionRecord.lastBarTime !== null && lastBar.time > subscriptionRecord.lastBarTime if (isNewBar) {if (bars.length < 2) {throw new Error('Not enough bars in history for proper pulse update. Need at least 2.'); } const previousBar = bars[bars.length - 2] subscriptionRecord.listener(previousBar) } subscriptionRecord.lastBarTime = lastBar.time console.log(lastBar) subscriptionRecord.listener(lastBar) } periodLengthSeconds =(resolution, requiredPeriodsCount) => { let daysCount = 0 if (resolution === 'D' || resolution === '1D') {daysCount = requiredPeriodsCount} else if (resolution === 'M' || resolution === '1M') {daysCount = 31 * requiredPeriodsCount} else if (resolution === 'W' || resolution === '1W') {daysCount = 7 * requiredPeriodsCount} else {daysCount = requiredPeriodsCount * parseInt(resolution) / (24 * 60) } return daysCount * 24 * 60 * 60 } } export default dataUpdater
-
socket.js 加入如下代码(也可以使用自己的 websocket 模块)
class socket {constructor(options) { this.heartBeatTimer = null this.options = options this.messageMap = {} this.connState = 0 this.socket = null } doOpen() {if (this.connState) return this.connState = 1 this.afterOpenEmit = [] const BrowserWebSocket = window.WebSocket || window.MozWebSocket const socketArg = new BrowserWebSocket(this.url) socketArg.binaryType = 'arraybuffer' socketArg.onopen = evt => this.onOpen(evt) socketArg.onclose = evt => this.onClose(evt) socketArg.onmessage = evt => this.onMessage(evt.data) // socketArg.onerror = err => this.onError(err) this.socket = socketArg } onOpen() { this.connState = 2 this.heartBeatTimer = setInterval(this.checkHeartbeat.bind(this), 20000) this.onReceiver({Event: 'open'}) } checkOpen() {return this.connState === 2} onClose() { this.connState = 0 if (this.connState) {this.onReceiver({ Event: 'close'}) } } send(data) {this.socket.send(JSON.stringify(data)) } emit(data) {return new Promise((resolve) => {this.socket.send(JSON.stringify(data)) this.on('message', (dataArray) => {resolve(dataArray) }) }) } onMessage(message) { try {const data = JSON.parse(message) this.onReceiver({Event: 'message', Data: data}) } catch (err) {// console.error('>> Data parsing error:', err) } } checkHeartbeat() { const data = { cmd: 'ping', args: [Date.parse(new Date())] } this.send(data) } onReceiver(data) {const callback = this.messageMap[data.Event] if (callback) callback(data.Data) } on(name, handler) {this.messageMap[name] = handler } doClose() {this.socket.close() } destroy() {if (this.heartBeatTimer) {clearInterval(this.heartBeatTimer) this.heartBeatTimer = null } this.doClose() this.messageMap = {} this.connState = 0 this.socket = null } } export default socket
初始化图表
- 可以同时请求 websocket 数据。
- 新建 init 函数,并在 onready/mounted/mounted 等时候去调用(代码的含义在注释里,我尽量写的详细一点)
+
init = () => {
var resolution = this.interval; // interval/resolution 当前时间维度
var chartType = (localStorage.getItem('tradingview.chartType') || '1')*1;
var locale = this.props.lang; // 当前语言
var skin = this.props.theme; // 当前皮肤(黑 / 白)
if (!this.widgets) {
this.widgets = new TradingView.widget({ // 创建图表
autosize: true, // 自动大小(适配,宽高百分百)
symbol:this.symbolName, // 商品名称
interval: resolution,
container_id: 'tv_chart_container', // 容器 ID
datafeed: this.datafeeds, // 配置,即 api 文件夹下的 datafees.js 文件
library_path: '/static/TradingView/charting_library/', // 图表库的位置,我这边放在了 static,因为已经压缩过
enabled_features: ['left_toolbar'],
timezone: 'Asia/Shanghai', // 图表的内置时区(常用 UTC+8)
// timezone: 'Etc/UTC', // 时区为(UTC+0)
custom_css_url: './css/tradingview_'+skin+'.css', // 样式位置
locale, // 语言
debug: false,
disabled_features: [ // 在默认情况下禁用的功能
'edit_buttons_in_legend',
'timeframes_toolbar',
'go_to_date',
'volume_force_overlay',
'header_symbol_search',
'header_undo_redo',
'caption_button_text_if_possible',
'header_resolutions',
'header_interval_dialog_button',
'show_interval_dialog_on_key_press',
'header_compare',
'header_screenshot',
'header_saveload'
],
overrides: this.getOverrides(skin), // 定制皮肤,默认无盖默认皮肤
studies_overrides: this.getStudiesOverrides(skin) // 定制皮肤,默认无盖默认皮肤
})
var thats = this.widgets;
// 当图表内容准备就绪时触发
thats.onChartReady(function() {createButton(buttons);
})
var buttons = [{title:'1m',resolution:'1',chartType:1},
{title:'15m',resolution:'15',chartType:1},
{title:'1h',resolution:'60',chartType:1},
{title:'1D',resolution:'1D',chartType:1},
];
// 创建按钮(这里是时间维度),并对选中的按钮加上样式
function createButton(buttons){for(var i = 0; i < buttons.length; i++){(function(button){
let defaultClass =
thats.createButton()
.attr('title', button.title).addClass(`mydate ${button.resolution === '15' ? 'active' : ''}`)
.text(button.title)
.on('click', function(e) {if (this.className.indexOf('active')> -1){// 已经选中
return false
}
let curent =e.currentTarget.parentNode.parentElement.childNodes
for(let index of curent) {if (index.className.indexOf('my-group')> -1 && index.childNodes[0].className.indexOf('active')> -1) {index.childNodes[0].className = index.childNodes[0].className.replace('active', '')
}
}
this.className = `${this.className} active`
thats.chart().setResolution(button.resolution, function onReadyCallback() {})
}).parent().addClass('my-group'+(button.resolution == paramary.resolution ? 'active':''))
})(buttons[i])
}
}
}
}
请求数据
- 新建 initMessage 函数 — 在需要去获取数据的时候,调取 initMessage。
-
initMessage = (symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback) => { let that = this // 保留当前回调 that.cacheData['onLoadedCallback'] = onLoadedCallback; // 获取需要请求的数据数目 let limit = that.initLimit(resolution, rangeStartDate, rangeEndDate) // 如果当前时间节点已经改变,停止上一个时间节点的订阅,修改时间节点值 if(that.interval !== resolution){ that.interval = resolution paramary.endTime = parseInt((Date.now() / 1000), 10) } else {paramary.endTime = rangeEndDate} // 获取当前时间段的数据,在 onMessage 中执行回调 onLoadedCallback paramary.limit = limit paramary.resolution = resolution let param // 分批次获取历史(这边区分了历史记录分批加载的请求) if (isHistory.isRequestHistory) { param = {// 获取历史记录时的参数(与全部主要区别是时间戳) } } else { param = {// 获取全部记录时的参数} } this.getklinelist(param) }
-
在请求历史数据时,由于条件不满足,会一直请求后台接口,所以需要加上 函数节流
- 在 lodash 这个库里面是有节流的方法的
- 首先引入节流函数 —-
import throttle from 'lodash/throttle'
-
使用非常简单,只要在函数前面套一层 —–
this.initMessage = throttle(this.initMessage, 1000);
- throttle()函数里面,第一个参数是需要截留的函数,第二个为节流时间。
收到数据,渲染图表
- 可以在接收数据的地方调用
socket.on('message', this.onMessage(res.data))
- onMessage 函数,是为渲染数据进入图表内容
-
// 渲染数据 onMessage = (data) => { // 通过参数将数据传递进来 let thats = this if (data === []) {return} // 引入新数据的原因,是我想要加入缓存,这样在大数据量的时候,切换时间维度可以大大的优化请求时间 let newdata = [] if(data && data.data) {newdata = data.data} const ticker = `${thats.symbolName}-${thats.interval}` // 第一次全部更新(增量数据是一条一条推送,等待全部数据拿到后再请求) if (newdata && newdata.length >= 1 && !thats.cacheData[ticker] && data.firstHisFlag === 'true') { // websocket 返回的值,数组代表时间段历史数据,不是增量 var tickerstate = `${ticker}state` // 如果没有缓存数据,则直接填充,发起订阅 if(!thats.cacheData[ticker]){thats.cacheData[ticker] = newdata thats.subscribe() // 这里去订阅增量数据!!!!!!!} // 新数据即当前时间段需要的数据,直接喂给图表插件 // 如果出现历史数据不见的时候,就说明 onLoadedCallback 是 undefined if(thats.cacheData['onLoadedCallback']){ // ToDo thats.cacheData['onLoadedCallback'](newdata) } // 请求完成,设置状态为 false thats.cacheData[tickerstate] = false // 记录当前缓存时间,即数组最后一位的时间 thats.lastTime = thats.cacheData[ticker][thats.cacheData[ticker].length - 1].time } // 更新历史数据 (这边是添加了滑动按需加载,后面我会说明) if(newdata && newdata.length > 1 && data.firstHisFlag === 'true' && paramary.klineId === data.klineId && paramary.resolution === data.resolution && thats.cacheData[ticker] && isHistory.isRequestHistory) {thats.cacheData[ticker] = newdata.concat(thats.cacheData[ticker]) isHistory.isRequestHistory = false } // 单条数据() if (newdata && newdata.length === 1 && data.hasOwnProperty('firstHisFlag') === false && data.klineId === paramary.klineId && paramary.resolution === data.resolution) { // 构造增量更新数据 let barsData = newdata[0] // 如果增量更新数据的时间大于缓存时间,而且缓存有数据,数据长度大于 0 if (barsData.time > thats.lastTime && thats.cacheData[ticker] && thats.cacheData[ticker].length) { // 增量更新的数据直接加入缓存数组 thats.cacheData[ticker].push(barsData) // 修改缓存时间 thats.lastTime = barsData.time } else if(barsData.time == thats.lastTime && thats.cacheData[ticker] && thats.cacheData[ticker].length){ // 如果增量更新的时间等于缓存时间,即在当前时间颗粒内产生了新数据,更新当前数据 thats.cacheData[ticker][thats.cacheData[ticker].length - 1] = barsData } // 通知图表插件,可以开始增量更新的渲染了 thats.datafeeds.barsUpdater.updateData()} }
逻辑中心 ===>getbars
- 新建 getbars 函数(该函数会在图表有变化时自动调用)
-
getBars = (symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback) => { const timeInterval = resolution // 当前时间维度 this.interval = resolution let ticker = `${this.symbolName}-${resolution}` let tickerload = `${ticker}load` var tickerstate = `${ticker}state` this.cacheData[tickerload] = rangeStartDate // 如果缓存没有数据,而且未发出请求,记录当前节点开始时间 // 切换时间或币种 if(!this.cacheData[ticker] && !this.cacheData[tickerstate]){this.cacheData[tickerload] = rangeStartDate // 发起请求,从 websocket 获取当前时间段的数据 this.initMessage(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback) // 设置状态为 true this.cacheData[tickerstate] = true } if(!this.cacheData[tickerload] || this.cacheData[tickerload] > rangeStartDate){ // 如果缓存有数据,但是没有当前时间段的数据,更新当前节点时间 this.cacheData[tickerload] = rangeStartDate; // 发起请求,从 websocket 获取当前时间段的数据 this.initMessage(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback); // 设置状态为 true this.cacheData[tickerstate] = !0; } // 正在从 websocket 获取数据,禁止一切操作 if(this.cacheData[tickerstate]){return false} // 拿到历史数据,更新图表 if (this.cacheData[ticker] && this.cacheData[ticker].length > 1) { this.isLoading = false onLoadedCallback(this.cacheData[ticker]) } else { let self = this this.getBarTimer = setTimeout(function() {self.getBars(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback) }, 10) } // 这里很重要,画圈圈 ---- 实现了往前滑动,分次请求历史数据,减小压力 // 根据可视窗口区域最左侧的时间节点与历史数据第一个点的时间比较判断,是否需要请求历史数据 if (this.cacheData[ticker] && this.cacheData[ticker].length > 1 && this.widgets && this.widgets._ready && !isHistory.isRequestHistory && timeInterval !== '1D') {const rangeTime = this.widgets.chart().getVisibleRange() // 可视区域时间值(秒) {from, to} const dataTime = this.cacheData[ticker][0].time // 返回数据第一条时间 if (rangeTime.from * 1000 <= dataTime + 28800000) { // true 不用请求 false 需要请求后续 isHistory.endTime = dataTime / 1000 isHistory.isRequestHistory = true // 发起历史数据的请求 this.initMessage(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback) } } }
小记
- tradingview 主要就是这几个函数之间的搭配。
- 使用
onLoadedCallback(this.cacheData[ticker])
或者this.datafeeds.barsUpdater.updateData()
去更新数据。 - 滑动加载时,可以先加载 200 条,后面每次 150 条,这样大大缩小了数据量,增加了渲染时间。
- 滑动加载时的节流会经常用到。
进阶 websocket
- 二进制传输数据
-
websocket 在传输数据的时候是明文传输,而且像 K 线上的历史数据,一般数据量比较大。为了安全性以及更快的加载出图表,我们决定使用二进制的方式传输数据。
- 可以通过使用 pako.js 解压二进制数据
- 引入 pako.js
yarn add pako -S
-
使用方法
if (res.data instanceof Blob) { // 看下收到的数据是不是 Blob 对象 const blob = res.data // 读取二进制文件 const reader = new FileReader() reader.readAsBinaryString(blob) reader.onload = () => { // 首先对结果进行 pako 解压缩,类型是 string,再转换成对象 data = JSON.parse(pako.inflate(reader.result, { to: 'string'})) } }
- 转换后,数据大小大概减少了 20%。
差不多了
写在最后
- 这里只分享些简单的内容,细节可以参照原生 js 版本的 Demo https://github.com/tenggouwa/…
- 关于滚动加载,以及二进制的内容有问题的可以评论留言。
- 如果这篇文章对你有帮助,或者是让您对 tradingview 有些了解,欢迎留言或点赞,我会一一回复。
- 笔者最大的希望就是您能从我的文章里获得点什么,我就很开心啦。。。
- 后面,至少每个月更新一篇文章。点赞关注不迷路啊,老铁。