前言

最近在做交易所项目里的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.jsyarn 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有些了解,欢迎留言或点赞,我会一一回复。
  • 笔者最大的希望就是您能从我的文章里获得点什么,我就很开心啦。。。
  • 后面,至少每个月更新一篇文章。点赞关注不迷路啊,老铁。