【首發於 我的個人部落格】
0. 竟然被催更了
前兩天公司領導居然提到我的部落格,說我最近懶了,不更新了……
趁放假,趕緊更新一輪……等等,什麼時候這變成工作了?
1. TradingView 是個啥
今天我們們說個比較特別的—— TradingView,這是一個專業的圖表庫,專門做 K 線圖的,而 K 線圖是股票、基金等交易所必備的一樣東西。專案本身是免費的,但並不開源,官方提供了託管在 Github 上的私有庫,開發者只需向官方提交一些必要的資訊,就可以獲取到訪問許可權。主倉庫包含了壓縮後的庫檔案,以及簡單的資料接入案例;Wiki 中提供了開發文件,同時還在其它的倉庫中提供了一些上手案例。
前端常用的幾個圖表庫,像 ECharts、DataV 其實都支援繪製基本的 K 線圖(有的稱之為蠟燭圖,叫法不同而已),配合柱狀圖和折線圖,還能繪製成交量、MA 等指標。TradingView 作為一款專業級的行業產品,除了前面提到的這些圖表,還提供了大量的專業測量工具,供專業的投資者和分析師使用,這些如果全部由開發者自行去實現,會需要花費大量的精力,這種一攬子打包的方案,無疑是它最吸引人的地方。
最近公司正在進行中的一個專案,就是一款數字資產的交易所,競品調研時候就發現,同行們幾乎無一例外的都選擇了這個圖表庫,連火幣、FCoin 等行業風向標級別的大廠都選擇了這款圖表庫,可見其在行業當中的權威性,以及近乎壟斷的地位。也正因為如此,我們也開始著手研究它。
2. 專業 === 麻煩
專業歸專業,但這畢竟是針對特定行業特定需求開發的東西,有很多的專業概念、術語、做法我們都不懂,得現學。官方雖然以 Wiki 的形式在 Github 中提供了文件,但文件的質量非常一般,看上去方方面面都覆蓋到了,但字裡行間充斥著大量晦澀難懂的概念,對引數的註解也是殘缺不齊,很多操作上的細節都沒有提到,閱讀體驗非常糟糕。雖然專案官網提供了中文的選項,圖表庫本身也支援多語言,但是文件卻只有英文的(雖然就我個人而言,語言本身並不構成壓力;但如果你需要,這裡 有一份別人整理的中文版的,還包含了基於 UDF 方案的視訊教程,作者來自 TradingView 專案組,是一位資深的開發者。為了講解方便,這裡會用到其中的一些圖,感謝 作者 )。
相比 ECharts、DataV 這種萬事俱備,只要填資料、配引數的“民用級”圖表庫,TradingView 的上手難度要高不少,它需要開發者按照其制定的規則,自行實現一套資料來源 API,官方雖然對於每一個 API 的作用、引數都給出了說明,但一些關鍵的點並沒有解釋清楚,很多開發者(包括我,和我接觸過的一些同行)在看過文件後還是沒能很好的理解“這 tm 到底該怎麼用”。寫這篇部落格,就是希望能夠為解決這個問題做一點貢獻,讓後來者能夠輕鬆一些。
3. 為了節約時間
先說明一點,這篇部落格並不會手把手教你一步一步搭建出整套東西。我假定你至少是先看過一遍官方的文件,並有了初步的嘗試之後,遇到問題,求助於搜尋引擎,然後才來到的這裡。
這篇部落格更像是一個 FAQ,根據我自己踩坑的經歷,把一些比較不好懂的東西,按我個人的理解分享給各位。
所以如果你指望這篇部落格能夠讓你不用去看官方文件就能夠完全掌握 TradingView,輕鬆把 K 線畫出來,那麼對不起,要讓你失望了。
4. 先說一下概念
TradingView 裡有一些比較專業的概念,不太好懂,但非常重要,這裡簡單說明一下。
4.1. Symbol
Symbol 直譯過來叫“象徵、符號”,這裡引申為“商品”。K 線表現的是價格的變化趨勢,至於是什麼東西的價格,可以是股票,可以是貨幣,也可以是任何一樣商品,TradingView 為了通用,提供了這麼一個抽象的概念。一個 Symbol 就是一個 JS 物件,描述了商品的一些屬性(名稱、價格小數位、支援的時間解析度、交易開放時間等,具體請參考官方文件),圖表庫會根據 Symbol 的定義,來決定改獲取怎樣的資料。
商品名稱的固定格式為 “EXCHANGE:SYMBOL”,SYMBOL 代表商品,例如一支股票、一個交易對;EXCHANGE 是交易所的名稱,同一商品在不同交易所可能會有不同的價格,因此需要進行區分。
4.2. Resolution
Resolution 直譯過來叫“解析度”,這裡指 K 線圖中相鄰兩條柱子之間的時間間隔,我沒研究過專業術語是不是就是用的這個詞,不過個人感覺這就是一種說法,你用別的詞也能表達這個意思,只不過 TradingView 選擇了這個詞。
4.3. Study
Study 直譯過來叫“學習、研究”,這裡解釋為“指標”,例如成交量、均線,以及其他各種分析指標。開發者可以通過 TradingView 提供的 API 自行新增。
4.4. Chart
圖表本體,特指 K 線圖及相關的各項指標,不包含工具欄。一個圖表例項可以包含多個指標
4.5. Widget
小部件,和 Android 上的 Widget 類似。Widget 可以看做是一個容器,主要是一些工具欄,以及留給繪製真正圖表的一塊區域,不含圖表本體。一個 Widget 可以包含多個圖表例項
4.6. FeatureSet
功能集,Widget 配置選項中的一部分,用於定製圖表庫的一些功能(包括顯示與否、樣式)。
4.7. Overrides
覆蓋,Widget 配置選項中的一部分,用於定製圖表庫的樣式(主要是圖表各部分的顏色)。整個圖表庫由外層 DOM 結構和內部多個 canvas 組成,因此樣式相關的設定也分為兩部分,這裡是用於 canvas 部分的設定,另外還有一個 custom_css_url
屬性用於指定一個 css 檔案,其中可以覆蓋 DOM 部分的樣式。具體的可以結合官方文件,以及 Chrome DevTool 來定位。
4.8. DataFeed
資料來源,也就是接下來要講的東西。它是 TradingView 獲取、處理資料的方法集合,也是 TradingView 資料接入的核心所在,需要使用者自己實現。它可以是一個 Class 的例項,也可以就是一個簡單的物件。
5. 如何接入自己的資料
建立圖表庫例項並不難,看過文件和上手案例的應該都能懂,難的在於怎麼把資料給填進去。相信絕大部分為 TradingView 頭疼的朋友都是卡在了這裡,只要資料接通了,剩下的都是小問題。
TradingView 之所能通用,在於它做到了資料和表現分離,圖表庫本身只提供表現的部分,不管你有什麼樣的資料,只要能整理成指定的格式填進去,就行。說白了,需要開發者自行實現一個介面卡。
TradingView 提供了兩種獲取資料的方式,基於 HTTP 的方案(UDF,Universal Data Feed,主倉庫中的演示案例就是用的這種),和基於 WebSocket 的方案(JS API)。
無論採用哪種方案,就資料而言都可以分為兩部分:截止到目前為止的歷史資料,以及之後新生成的資料。
5.1. UDF 的方案
UDF 是 TradingView 自己定義的一套協議。本質上其實也是呼叫的 JS API。協議基於 HTTP + 輪詢,通過 HTTP 請求查詢指定條件下的歷史資料,然後不斷輪詢檢查是否有新資料。這套方案非常簡單,前端部分已經定義好,只要照著案例中提供的演示程式碼接入介面就可以了(演示程式碼是用 TypeScript 寫的,有一點點額外的認知成本,不過問題不大),主要工作在於後端,需要按照要求提供相應的查詢介面,其中最核心的就是獲取指定商品、指定解析度、指定時間範圍的資料,具體格式參考官方文件即可。這裡我們就不展開了。
輪詢——我們知道是一種有效但非常不推薦的做法(除非環境不支援 WebSocket,那隻能用它),因為很多時候是輪不到新資料的,非常浪費效能。我們更希望的是每當有新資料到來時,能夠主動通知我們,這也就引出了下面的方案。
5.2. JS API
這是 TradingView 資料接入的核心,通過這套 API 開發者可以接入任何型別的資料,當然最常見的還是 WebSocket。前面所說的 UDF 的方案其實也是呼叫的這幾個 API。官方文件對各個 API 都進行了描述,其中必備的有 onReady()
、resolveSymbol()
、getBars()
、subscribeBars()
、unsubscribeBars()
,剩下的根據需要自行實現,這裡我們只說最基本的使用。前兩個沒什麼難度,我們重點來看下後面幾個。(這裡我們以 DataFeed 類的例項方法的形式來實現,你也可以簡單建立一個包含這些函式的 JS 物件)
5.2.1. getBars()
這個介面專門用於獲取歷史資料,即當前時刻之前的資料。TradingView 會根據 Resolution 從當前時刻開始往前劃定一個時間範圍,嘗試獲取這個時間範圍內,指定 Symbol 指定 Resolution 的資料。出於效能考慮,TradingView 只獲取可見範圍內的資料,超出可見範圍的資料會隨著圖表的拖拽、縮放而分段延遲載入。
這部分的實現程式碼比較多,我們一步步來,先來實現一個傳送資料的內部函式:
getBars (symbolInfo, resolution, from, to, onHistoryCallback, onErrorCallback, firstDataRequest) {
function _send (data) {
// 按時間篩選
const dataInRange = data.length
? data.filter(n => n.time >= from && n.time <= to)
: []
// 沒有資料就返回 noData
const meta = {
noData: !dataInRange.length
}
// 有資料,則整理成圖表庫要求的格式
const bar = [...dataInRange]
// 觸發回撥
onHistoryCallback(bar, meta)
}
}
複製程式碼
我們把這個函式作為 getBars()
的內部函式,其中 from
、to
、onHistoryCallback
是 API 提供的引數,data
是我們獲取到的資料,(bar, meta)
是 TradingView 要求的固定格式。
這個函式負責呼叫回撥函式,把我們獲取到的資料傳給圖表。接下來,我們來獲取資料(演示程式碼,一些涉密、相容的程式碼已經省略,只保留最基本的、可公開的邏輯):
getBars (symbolInfo, resolution, from, to, onHistoryCallback, onErrorCallback, firstDataRequest) {
function _send (data) {
// ...
}
// 一個簡單的工具函式,實現倒序查詢
// 可以簡單理解為 Array.prototype.findIndex 的倒序版本
// 後面會用到
function _findLastIndex (arr, fn) {
for (var i = arr.length - 1; i >= 0; i--) {
if (fn(arr[i])) return i
}
return -1
}
// 出於資料共享的需要
// 我們把獲取到的資料放到 Redux 裡
// 先嚐試從 Redux 獲取現有資料
const existingData = store.getState().kChartData || []
// 如果 Redux 中已有資料,則直接讀取
if (existingData.length) {
_send(existingData)
return
}
// 如果 Redux 中沒資料,則通過 WebSocket 載入
// 我們的設計是歷史資料和實時更新都走 WebSocket
// 首次推送歷史資料,後續推送更新
// 所以同一交易對、解析度,只會發起一個 WebSocket 請求
// 先判斷功能支援度
// 這裡我們用 WebWorker 把 WebSocket 的邏輯獨立到主執行緒之外
// 以達到效能優化的目的,這個後面再詳述。
if (!window.Worker) return
// 限制 Worker 單例
const hasWSInstance = !!window.kChartWorker
window.kChartWorker = window.kChartWorker || new window.Worker('./worker-kchart.js')
// WebWorker 資料推送回撥
window.kChartWorker.onmessage = e => {
const { data = {} } = e
// 當有資料推送時
if (data.kChartData) {
// 獲取已有資料
const kChartData = store.getState().kChartData
// 增量更新
for (const item of data.kChartData) {
// 因為 K 線的資料是按時間順序排列的,
// 資料的更新都在末端,所以倒序搜尋更快
const idx = _findLastIndex(kChartData, n => n.time === item.time)
idx < 0
? kChartData.push(item)
: kChartData[idx] = { ...kChartData[idx], ...item }
}
// 把新資料記錄到 Redux
const promise = new Promise((resolve, reject) => {
store.dispatch(setKChartData(kChartData))
resolve({
full: kChartData, // 最新的完整資料
updates: data.kChartData // 本輪更新的內容
})
})
promise.then(res => {
// dataInited 是我們自定義的一個變數
// 用來區分首次推送和後續推送
// 初始為 false,首次推送後置為 true
if (this.dataInited) {
// 如非首次推送
// 對全域性 K 線訂閱列表中的每個訂閱者(後面詳述)
window.kChartSubscriberList = window.kChartSubscriberList || []
for (const sub of window.kChartSubscriberList) {
// 按交易對、解析度篩選
if (sub.symbol !== this.symbol) return
if (sub.resolution !== resolution) return
// 通過回撥函式推送資料
if (typeof sub.callback !== 'function') return
// 圖表庫一次只能增加一條資料,或更新離現在時間最近的一條歷史資料
// 而我們的推送資料是個陣列,可能會包含不止一條資料
// 所以這裡要逐個推送
for (const update of res.updates) {
sub.callback(update)
}
}
} else {
// 首次推送
_send(res.full)
this.dataInited = true
}
})
}
}
// 準備 WebWorker 訊息
// 只有當沒有現成資料的時候才會執行到這裡
// 因此只有在初始化、切換交易對/解析度的時候
// 才會發起 WebSocket 請求
const msg = {
// action 表示行為目的
// init 為初始化
// restart 為切換交易對/解析度
// 對應不同的 WebSocket 操作
action: hasWSInstance ? 'restart' : 'init',
symbol: symbolInfo,
resolution: resolution,
url: WEBSOCKET_URL
}
// 傳送 WebWorker 訊息
window.kChartWorker.postMessage(msg)
}
複製程式碼
到這裡,我們已經成功獲取到歷史資料,並把實時更新的推送傳送給了各個訂閱者(雖然理論上可能始終只有一個訂閱者,但從系統設計角度,我們還是按照多個來設計)。
WebSocket 的具體操作和 TradingView 其實沒有關係,你可以選擇任何你熟悉的方式,這裡我們就不贅述,只是告知發起的時機和回撥的處理方式。
getBars()
其實還好,一旦搞清楚了其工作機制,其實沒什麼特別難的,更多的是資料結構的設計以及效能方面的優化。相信令很多人費解的是下面這個函式。
5.2.2. subscribeBars()
文件中說這個函式是用來訂閱 K 線資料的,再加上“getBars()
的 onHistoryCallback
回撥僅一次呼叫”,這兩句話誤導了不少人,以為 getBars()
只會被呼叫一次,獲取完歷史資料就結束了,實時推送的獲取需要在 subscribeBars()
裡實現。事實上,這裡只是增加一個訂閱者,把新增更新資料的回撥函式存到外層,回撥函式的呼叫實際是在前面 getBars()
裡完成的。相當於這個函式只是排個隊,所有資料的獲取和分發都在 getBars()
裡進行。
subscribeBars (symbolInfo, resolution, onRealtimeCallback, subscriberUID, onResetCacheNeededCallback) {
// 限制單例
window.kChartSubscriberList = window.kChartSubscriberList || []
// 避免重複訂閱
const found = window.kChartSubscriberList.some(n => n.uid === subscriberUID)
if (found) return
// 新增訂閱
window.kChartSubscriberList.push({
symbol: symbolInfo,
resolution: resolution,
uid: subscriberUID,
callback: onRealtimeCallback
})
}
複製程式碼
這個函式對每個 Symbol + Resolution 的組合都會呼叫一次,把對應的識別資訊和回撥函式傳遞到訂閱列表,當推送資料到達時,會遍歷訂閱列表,找到符合條件的訂閱者,呼叫其回撥函式傳遞資料。其實就是個基本的“觀察者模式”。
5.2.3. unsubscribeBars()
瞭解完 subscribeBars()
,那其實 unsubscribeBars()
也就很明白了,簡單帶過:
unsubscribeBars (subscriberUID) {
window.kChartSubscriberList = window.kChartSubscriberList || []
const idx = window.kChartSubscriberList.findIndex(n => n.uid === subscriberUID)
if (idx < 0) return
window.kChartSubscriberList.splice(idx, 1)
}
複製程式碼
6. 如何切換交易對/解析度
建立完 widget 例項之後,就可以通過特定的方法獲取 chart 例項,然後通過特定方法更新 Symbol 和 Resolution,更新操作會以新的引數重新觸發之前提到的幾個函式。從這個角度看,這幾個函式就有點像是生命週期函式,描述了獲取資料、訂閱更新等一列的操作發生的時機,有開發者決定什麼時候該做什麼事。
this.widget = new window.TradingView.widget(widgetOptions)
this.widget.onChartReady(() => {
this.chart = this.widget.chart()
// 設定圖表型別(比如分時圖和常規的蠟燭圖的型別就不一樣)
this.chart.setChartType(chartType)
// 切換 Symbol
this.chart.setSymbol(symbol, callback)
// 切換 Resolution
this.chart.setResolution(resolution, callback)
})
複製程式碼
7. TradingView 的其他坑
- JS API 中的函式,會在合適的時機自動呼叫,並傳入實參,不用考慮把函式拿到外層去手動呼叫。
- JS API 中的
onReady()
和resolveSymbol()
這兩個函式,它們的回撥函式必須非同步呼叫,別問為什麼,人家要求的。 - 切換 Symbol 和 Resolution 的函式都有一個回撥,如果設定的新值和當前現有的值相同,這個回撥是不會觸發的。
8. K 線效能優化
在使用 WebSocket 的過程中,我們用到了 WebWorker 進行效能優化。
當交易頻率達到一定的程度,WebSocket 會頻繁向客戶端推送資料,如果把這部分邏輯直接放到 React 元件中,一有新資料就去 setState()
,那麼頁面立馬就會被卡得死死的(慘痛的教訓)。原理也很簡單,間隔時間極短的 setState()
會被快取起來,合併成一次去更新,以減少不必要的計算和渲染,如果資料持續頻繁地灌進來,就會攢下一大堆的更新沒有被 commit,元件始終進入不了下一輪的 render;加上每次新資料進來都需要和老資料進行增量合併,高頻率高負荷的計算會佔用主執行緒的資源,導致沒有足夠的運算資源用於頁面渲染,頁面也就卡死了。
明白了這一點,那麼方案也就出來了,就是把這些計算密集型的任務從主執行緒裡拿出去,交給併發執行緒,也就是 WebWorker,去執行。
但光是把計算交出去還不夠,雖然主執行緒的計算負載下來了,但更新還是很頻繁。
科學資料顯示,人眼的視覺停留時間大約在 0.1 秒左右,也就是說,即便真的讓頁面上的數字一秒變化個十幾次甚至更多,人眼也根本來不及看清楚,從使用的角度來講,1 秒變化個 4-5 次已經是極限了,即便 0.5 秒更新一次也完全不影響,所以大可不必按照 WebSocket 資料推送的頻率去更新頁面,我們完全可以建立一個緩衝帶,把 WebSocket 推送過來的資料快取到一個陣列裡,每隔固定時間間隔去檢查陣列是否有內容,有就通知主執行緒更新,沒有就啥也別做,這樣就在效能和效果之間找到了一個平衡點。
有些人會關心 WebWorker 的相容性問題,畢竟一般的 H5 頁很少會用到這個,不太熟。WebWorker 的瀏覽器相容情況和 WebSocket 大致相同,至少在我們關心的範圍內,是一致的,都是 IE 10 及以上,常青藤瀏覽器不用多說早就都支援了,所以除非你還有必須相容老古董的需求,放心用好了。
9. 小結
交易所的這個專案,應該算是近年來接手的比較大的一個專案了,涉及的東西很多,其中不少之前都沒接觸過,都是現學現賣。過程中遇到了不少的坑,也有了不小的成長。後續我還會分享一些其他方面遇到的坑。