TradingView--最專業的走勢圖表,收下吧,也許你會用到

tenggouwa發表於2019-07-09

前言

最近在做交易所專案裡的K線圖,得些經驗,與大家分享。
程式碼居多,流量預警!!!!
點贊 收藏 不迷路。

技術選型

  • echarts
    • TradingView--最專業的走勢圖表,收下吧,也許你會用到
    • 基於canvas繪製,種類齊全的視覺化圖表庫。
    • 官方地址: echarts.baidu.com/
  • highcharts
  • tradingview
  • 優缺點
    • hightcharts: 前些日子有仔細研究過 hightcharts www.fota.com/option/。 發現svg中的dom操作,以及定製化內容更好實現,但幾乎都需要手動實現的這個特性在開發週期短的壓迫下屈服了。上面的這個專案在慢慢摸索下也做了小三個月的樣子,但還是很有成就感的。
    • echarts: echarts的官方案例很多,經常在做一些後臺管理系統,展現資料時候會用到,方便,易用,使用者也足夠多,搜尋引擎雞本能夠解決你的任何問題。但對一些在圖上劃線,等操作,就顯得略微疲軟。不夠能滿足需求。
    • tradingview: 只要進入官網,就可見其專業性,他完全就是為了專業交易兒打造的,您只需要想裡面填充資料就可以了,甚至在一些常用的交易內容上,可以使用tradingview自己的資料推送。
  • 小記
    • 所以,專業的交易圖表,就交給專業的庫來做吧
    • 手動狗頭~~~~(∩_∩)

準備工作

  • 申請賬號(key)
    • 在官網註冊後會有郵件提示的,一步一步跟著做就可以了,這裡就不做贅述了。
  • 環境搭建
    • 我使用的是自己搭建的React+webpack4腳手架,你也可以使用原生JS,或者你喜歡的任何框架(後面貼出來的程式碼都是在React環境下的)。
    • 從官方下載程式碼庫
      • TradingView--最專業的走勢圖表,收下吧,也許你會用到
  • 瞭解websocket通訊協議
    • 傳送請求
    • 接收資料
  • 大綱
  • 一位大神的Demo github.com/tenggouwa/t…

準備開始吧


建立

  • page
    |--kLine // k線內容資料夾
    |--|--api // 需要使用的方法
    |--|--|--datafees.js // 定義了一些公用方法
    |--|--|--dataUpdater.js // 更新時呼叫的內容
    |--|--|--socket.js // websocket方法
    |--|--index.js // 自己程式碼開發
    |--|--index.scss // 樣式開發
    複製程式碼
  • datafees.js加入如下程式碼

        /**
         * JS API
         */
        import React from 'react'
        import DataUpdater from './dataUpdater'
        
        class datafeeds extends React.Component {
        /**
         * JS API
         * @param {*Object} react react例項
         */
            constructor(self) {
                super(self)
                this.self = self
                this.barsUpdater = new DataUpdater(this)
                this.defaultConfiguration = this.defaultConfiguration.bind(this)
            }
            /**
             * @param {*Function} callback  回撥函式
             * `onReady` should return result asynchronously.
             */
            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))
            }
            /**
             * @param {*Object} symbolInfo  商品資訊物件
             * @param {*String} resolution  解析度
             * @param {*Number} rangeStartDate  時間戳、最左邊請求的K線時間
             * @param {*Number} rangeEndDate  時間戳、最右邊請求的K線時間
             * @param {*Function} onDataCallback  回撥函式
             * @param {*Function} onErrorCallback  回撥函式
             */
            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)
                /* eslint-enable */
            }
            /**
             * @param {*String} symbolName  商品名稱或ticker
             * @param {*Function} onSymbolResolvedCallback 成功回撥
             * @param {*Function} onResolveErrorCallback   失敗回撥
             * `resolveSymbol` should return result asynchronously.
             */
            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))
            }
            /**
             * 訂閱K線資料。圖表庫將呼叫onRealtimeCallback方法以更新實時資料
             * @param {*Object} symbolInfo 商品資訊
             * @param {*String} resolution 解析度
             * @param {*Function} onRealtimeCallback 回撥函式
             * @param {*String} subscriberUID 監聽的唯一識別符號
             * @param {*Function} onResetCacheNeededCallback (從1.7開始): 將在bars資料發生變化時執行
             */
            subscribeBars(symbolInfo, resolution, onRealtimeCallback, subscriberUID, onResetCacheNeededCallback) {
                this.barsUpdater.subscribeBars(symbolInfo, resolution, onRealtimeCallback, subscriberUID, onResetCacheNeededCallback)
            }
            /**
             * 取消訂閱K線資料
             * @param {*String} subscriberUID 監聽的唯一識別符號
             */
            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線上的歷史資料,一般資料量比較大。為了安全性以及更快的載入出圖表,我們決定使用二進位制的方式傳輸資料。

    • TradingView--最專業的走勢圖表,收下吧,也許你會用到
    • 可以通過使用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 github.com/tenggouwa/t…
  • 關於滾動載入,以及二進位制的內容有問題的可以評論留言。
  • 如果這篇文章對你有幫助,或者是讓您對tradingview有些瞭解,歡迎留言或點贊,我會一一回復。
  • 筆者最大的希望就是您能從我的文章裡獲得點什麼,我就很開心啦。。。
  • 後面,至少每個月更新一篇文章。點贊關注不迷路啊,老鐵。

相關文章