[數據可視化]繪製持倉榜單的“棒棒糖圖”

1. 需求

做股票分析的朋友經常會見到類似這種的期貨公司持倉榜單圖:

這種圖就是棒棒糖圖。也就是我們今天文章的目標:

繪製出期貨持倉榜單的棒棒糖圖

圖中線的兩端是圓點或者菱形,旁邊都有標註持倉證券商和相對應的持多倉數或持空倉數,且左右線顏色不同。畫圖思路大體就是:先畫水平線圖,再用 scatter 散點圖畫線左右兩端的點,然後標註兩端名稱,以及標題和註解。

Python 中比較常用的兩種圖表庫是 matplotlib 和 plotly。上圖就是以 matplotlib 繪製。而 Plotly交互性更好。

更進一步,如果想讓用戶可以點擊選擇交易日期,查看該日期對應的榜單圖,這就可以通過一個響應式 web 應用程序來實現。Dash 是一個基於 python 的交互式可視化 web 應用框架,matplotlib 和 Plotly 都可與 Dash 框架結合使用。

Matplotlib 大家比較熟悉。在開始之前,我們先簡單介紹下 plotly 和 Dash。

2. Plotly

plotly 庫(plotly.py)是一個交互式的開源繪圖庫,支持40多種獨特的圖表類型,涵蓋各種統計,財務,地理,科學和三維用例,是適用於Python,R 和 JavaScript 的交互式圖表庫。

plotly.py 建立在 Plotly JavaScript 庫(plotly.js)之上,使Python用戶可以創建基於 Web 的漂亮交互式可視化效果。這些可視化效果可以顯示在 Jupyter 筆記本中,可以保存到獨立的 HTML 文件中,也可以作爲純 Python 使用。其官方文檔上提供了各種圖標的接口說明。

3. Dash

Dash 是用於構建 Web 應用程序的 Python 框架。Dash 建立在 Flask、Plotly.js 和 React.js 基礎之上,即 Dash 中的控件和其觸發事件都是用React.js包裝的,Plotly.js 爲 Dash 提供強大的交互式數據可視化圖庫,Flask 爲其提供後端框架。這個框架對 python 程序員特別友好,只需要寫 python 代碼,不需要寫 JS 代碼,直接拖拽控件來用即可。感興趣的童鞋可以去 Dash 的官方文檔多多瞭解一下。Dash 是使用純 Python 構建高度自定義用戶界面的數據可視化應用程序的理想選擇。它特別適合做數據分析、數據可視化以及儀表盤或者報告展示。可以將Dash應用程序部署到服務器,然後通過 URL 共享它們,不受平臺和環境的限制。

4. 安裝

在畫圖之前,我們需要裝一下 Dash、plotly 相關包。可以用 pip 裝:

pip install plotly dash

或者也可以用 conda 進行安裝。

5. 數據格式和數據處理

測試數據來自東方財富網,用 csv 文件格式保存。

數據的格式如下,header 是日期爲第一列,第3列往後爲期貨公司名字。表格中的負數是上面圖中藍色的空倉,正數是紅色的多倉。繪圖時,從表格中取出某一日期的一行記錄,將持倉數目排序,把對應的數據存入列表中,之後進行畫圖。

首先對數據進行清洗和處理, pandas讀取數據,這裏需要去除 000905_SH 列,以及刪除全0行。代碼如下:

excel_pd = pd.read_excel('data/IC期貨商曆史數據(1).xlsx', index_col='日期')
# 去空
excel_pd.dropna()
# 去除000905_SH列
excel_pd = excel_pd.drop(labels='000905_SH', axis=1)
# 去0行
excel_pd = excel_pd[~(excel_pd == 0).all(axis=1)]
# 取出時間列表,獲取最大日期和最小日期,爲日曆選項做判斷
date_list = excel_pd.index.values.tolist()
min_date = min(date_list)
max_date = max(date_list)

接下來我們需要根據輸入的日期來篩選某一行記錄,分別將持空倉期貨公司和持多倉期貨公司取出,剔除持倉數爲0的期貨公司。代碼如下:

def get_data_via_date_from_excel(date):
    # 篩選日期
    sheet1_data = excel_pd.loc[date]
    # 去除列值爲0
    sheet1_data = sheet1_data[sheet1_data != 0]
    # 排序 從小到大
    sheet1_data = sheet1_data.sort_values()
    # 空倉
    short_hold = sheet1_data[sheet1_data < 0]
    # 多倉
    long_hold = sheet1_data[sheet1_data >= 0].sort_values(ascending=False)
    return short_hold, long_hold

6. 畫圖

Matplotlib畫圖

創建一張畫布figure和ax畫圖層,用ax.hlines分別畫空倉水平線和多倉水平線。用ax.scatter畫左右兩邊線的散點,使用菱形marker。使用plt.text分別畫線兩端的標註期貨公司和持倉數。plt.annotate畫排名標註,分別設置顏色和字體大小。

但這個效果是反的,我們是希望排名最前面的在上,排名最後面的下。這時我們可以設置y軸反置一下ax.invert_yaxis()。添加圖例和標題以及設置座標軸不可見,得到最終效果:

核心代碼如下:

def draw_lollipop_graph(short_hold, long_hold, date):
    # sheet_major.index.values.tolist()
    fig, ax = plt.subplots(figsize=(10, 8))
    # 空倉水平線
    ax.hlines(y=[i for i in range(len(short_hold))], xmin=list(short_hold), xmax=[0] * len(short_hold.index), color='#1a68cc', label='空')
    # 多倉水平線
    ax.hlines(y=[i for i in range(len(long_hold))], xmax=list(long_hold), xmin=[0] * len(long_hold.index), color='red', label='多')
    # 畫散點
    ax.scatter(x=list(short_hold), y=[i for i in range(len(short_hold))], s=10, marker='d', edgecolors="#1a68cc", zorder=2, color='white')  # zorder設置該點覆蓋線
    ax.scatter(x=list(long_hold), y=[i for i in range(len(long_hold))], s=10, marker='d', edgecolors="red", zorder=2, color='white')  # zorder設置該點覆蓋線
    # 畫線兩端標註圖
    for x, y, label in zip(list(short_hold), range(len(short_hold)), short_hold.index):
        plt.text(x=x, y=y, s=label+'({}) '.format(abs(x)), horizontalalignment='right', verticalalignment='center', fontsize=10)
    for x, y, label in zip(list(long_hold), range(len(long_hold)), long_hold.index):
        plt.text(x=x, y=y, s=' '+label+'({})'.format(abs(x)), horizontalalignment='left', verticalalignment='center', fontsize=10)
    # 設置排名
    size = [17, 16, 15] + [8 for i in range(max(len(short_hold), len(long_hold))-3)]
    color = ['#b91818', '#e26012', '#dd9f10'] + ['#404040' for i in range(max(len(short_hold), len(long_hold))-3)]
    for i, s, c in zip(range(max(len(short_hold), len(long_hold))+1), size, color):
        plt.annotate(s=i+1, xy=(0, i), fontsize=s, ma='center', ha='center', color=c)
    # 座標軸y反置
    ax.invert_yaxis()
    # 座標軸不可見
    ax.set_xticks([])
    ax.set_yticks([])
    ax.spines['top'].set_visible(False)  # 去上邊框
    ax.spines['bottom'].set_visible(False)  # 去下邊框
    ax.spines['left'].set_visible(False)  # 去左邊框
    ax.spines['right'].set_visible(False)  # 去右邊框
    # 設置title
    ax.set_title('黃金持倉龍虎榜單({})'.format(date), position=(0.7, 1.07), fontdict=dict(fontsize=20, color='black'))
    # 自動獲取ax圖例句柄及其標籤
    handles, labels = ax.get_legend_handles_labels()
    plt.legend(handles=handles, ncol=2, bbox_to_anchor=(0.75, 1.05), labels=labels, edgecolor='white', fontsize=10)
    # 保存fig
    image_filename = "lollipop_rank.png"
    plt.savefig(image_filename)
    encoded_image = base64.b64encode(open(image_filename, 'rb').read())
    # plt.show()
    return encoded_image

Plotly畫圖

1) Figure 是一張畫布,跟 matplotlib 的 figure 是一樣,數據是字典形式,創建代碼如下:

import plotly.graph_objects as go
fig = go.Figure() # 創建空畫布
fig.show()

2) Traces 軌跡,即所有的圖表層都是在這裏畫的,軌跡的類型都是由type指定的(例如"bar","scatter","contour"等等)。軌跡是列表,創建代碼如下:

fig = go.Figure(data=[trace1, trace2]) # 定義figure時加上軌跡數據
Figure.add_traces(data[, rows, cols, ])   # 或者先定義一張空的畫布,再添加軌跡
Figure.update_traces([patch, selector, row, ])  # 更新軌跡
# 可運行代碼
import plotly.graph_objects as go

trace = [go.Scatter(  # 創建trace
    x=[0, 1, 2],
    y=[2, 2, 2],
    mode="markers",
    marker=dict(color="#1a68cc", size=20),
)]
fig = go.Figure(data=trace)
fig.show()

3) Layout 層,設置標題,排版,頁邊距,軸,註釋,形狀,legend圖例等等。佈局配置選項適用於整個圖形。

import plotly.graph_objects as go

trace = [go.Scatter(
    x=[0, 1, 2],
    y=[2, 2, 2],
    mode="markers",
    marker=dict(color="#1a68cc", size=20),
)]
# 創建layout,添加標題
layout = go.Layout(
        title=go.layout.Title(text="Converting Graph Objects To Dictionaries and JSON")
    )
fig = go.Figure(data=trace, layout=layout)
fig.show()
Figure.update_layout([dict1, overwrite]) # 也可使用API更新圖層

4) Frames 幀幅軌跡,是在Animate中用到的渲染層,即每多少幀幅動畫遍歷的軌跡,與traces很像,都是列表形式,使用時需要在layout的updatemenus設置幀幅間隔等等。具體用法可以去看看官方文檔用法,比較複雜,這裏不過多介紹。下面迴歸正題,我們需要創建一張畫布figure來畫圖。

畫圖1:水平線

由於plotly沒有matplotlib的ax.hlines函數畫水平線,可以藉助plotly shapes畫水平線。 畫shapes圖需要知道該點座標(x1,y1)還要找到對應的(0,y1)座標點並連線組成一個shape,這裏x1是持倉數,y1就用持倉列表的下標表示。

# 空倉水平線
short_shapes = [{'type': 'line',
                 'yref': 'y1',
                 'y0': k,
                 'y1': k,
                 'xref': 'x1',
                 'x0': 0,
                 'x1': i,
                 'layer': 'below',
                 'line': dict(
                     color="#1a68cc",
                 ),
                 } for i, k in zip(short_hold, range(len(short_hold)))]
# 多倉水平線
long_shapes = [{'type': 'line',
                'yref': 'y1',
                'y0': k,
                'y1': k,
                'xref': 'x1',
                'x0': j,
                'x1': 0,
                'layer': 'below',
                'line': dict(
                  color="red",
                 )
                } for j, k in zip(long_hold, range(len(long_hold)))]

畫圖2:線兩端散點和標註

用scatter畫左右兩邊線的散點,使用菱形marker並且scatter中的text可以標註線兩端的標註期貨公司和持倉數,注意持倉數都是正數。

# 畫散點
fig.add_trace(go.Scatter(
    x=short_hold,
    y=[i for i in range(len(short_hold))],
    mode='markers+text',
    marker=dict(color="#1a68cc", symbol='diamond-open'),
    text=[label + '(' + str(abs(i)) + ') ' for label, i in zip(short_hold.index, short_hold)],   # 散點兩端的期貨公司標註和持倉數
    textposition='middle left', # 標註文字的位置
    showlegend=False            # 該軌跡不顯示圖例legend
))

fig.add_trace(go.Scatter(
    x=long_hold,
    y=[i for i in range(len(long_hold))],
    mode='markers+text',
    text=[' ' + label + '(' + str(abs(i)) + ')' for label, i in zip(long_hold.index, long_hold)],  # 散點兩端的期貨公司標註和持倉數
    marker=dict(color='red', symbol='diamond-open'),
    textposition='middle right',  # 標註文字的位置
    showlegend=False           # 該軌跡不顯示圖例legend
))

畫圖3:排名標註

繼續用scatter只顯示text來畫排名標註,分別設置顏色和字體大小。

# 線上的排名順序
fig.add_trace(go.Scatter(
    x=[0]*max(len(short_hold), len(long_hold)),
    y=[i for i in range(max(len(short_hold), len(long_hold)))],
    mode='text',
    text=[str(i+1) for i in range(max(len(short_hold), len(long_hold)))],  # 排名從1開始
    textfont=dict(color=['#b91818', '#e26012', '#dd9f10'] + ['#404040' for i in range(max(len(short_hold), len(long_hold)) - 3)],
                  size=[17, 16, 15] + [10 for i in range(max(len(short_hold), len(long_hold)) - 3)],
                  family="Open Sans"),
    textposition='top center',
    showlegend=False
))

畫圖4:圖例

由於plotly shapes不是軌跡,只是layout中的一部分,所以不能添加legend,而上面的散點scatter雖是軌跡,但是mode =markers+text 使得legend中多出了text文字,如下圖,而且目前版本的plotly不具備自定義legend去除text功能。

所以我們需要自己添加2條軌跡來顯示legend圖例,代碼如下:

# 加上這條trace只是爲了顯示legend圖例,因爲scatter圖例中顯示的text在plotly現有的版本基礎上去除不了
fig.add_trace(go.Scatter(
    x=[0, long_hold[0]],
    y=[range(len(long_hold))[0], range(len(long_hold))[0]],
    mode='lines',
    marker=dict(color='red'),
    name='多'
))

fig.add_trace(go.Scatter(
    x=[0, short_hold[0]],
    y=[range(len(short_hold))[0], range(len(short_hold))[0]],
    mode='lines',
    marker=dict(color='#1a68cc'),
    name='空'
))

設置y軸反置 autorange='reversed' 可讓排名最前面的在上,排名最後面的在下,之後設置圖裏位置,添加標題以及設置座標軸不可見, 代碼如下:

# X, Y座標軸不可見
fig.update_xaxes(
    showticklabels=False,
    showgrid=False,
    zeroline=False,)
fig.update_yaxes(
    showticklabels=False,
    showgrid=False,
    zeroline=False,
    autorange='reversed'  # Y 軸倒置)
fig.update_layout(
    shapes=short_shapes+long_shapes,  # 添加水平線
    width=2100,
    height=900,
    legend=dict(x=0.62, y=1.02, orientation='h'),
    template="plotly_white",
    title=dict(
        text='黃金持倉龍虎榜單(' + date + ')',
        y=0.95,
        x=0.65,
        xanchor='center', 
        yanchor='top', 
        font=dict(family="Open Sans", size=30)
    )
)


7. 創建Dash 應用程序

這裏首先創建一個Dash app程序。Dash應用程序由兩部分組成。 第一部分是應用程序的“佈局”,它描述了應用程序的外觀,即使用的web界面控件和CSS等,dash_core_components和dash_html_components庫中提供一組用react.js包裝好的組件,當然熟悉JavaScript和React.js也可構建自己的組件。第二部分描述了應用程序的交互性,即觸發callback函數實現數據交互。

這裏我們需要調用Dash中的日曆控件dcc.DatePickerSingle,具體用法可以參考官方文檔, 還有一個可以放置dcc.Graph圖的容器html.Div()。同時通過callback函數來捕捉日期更新從而畫圖事件。

import dash
import dash_html_components as html
import dash_core_components as dcc

external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']
app = dash.Dash(__name__, external_stylesheets=external_stylesheets)

app.layout = html.Div([
    html.Div(dcc.DatePickerSingle(
        id='my-date-picker-single',
        min_date_allowed=min_date,  # 日曆最小日期
        max_date_allowed=max_date,  # 日曆最大日期
        date=max_date   # dash 程序初始化日曆的默認值日期
    ), style={"margin-left": "300px"}),
    html.Div(id='output-container-date-picker-single', style={"text-align": "center"})
])

Matplotlib + Dash 框架

之前我們用matplotlib畫好的榜單圖已經編碼保存好,注意這裏畫的圖是靜態圖,觸發日期更新畫matplotlib畫圖事件代碼如下:

@app.callback(
    Output('output-container-date-picker-single', 'children'),
    [Input('my-date-picker-single', 'date')])
def update_output(date):
    print("date", date)
    if date is not None:
        if date not in date_list:
            return html.Div([
               "數據不存在"
            ])
        encoded_image = create_figure(date)
        return html.Div([
            html.Img(src='data:image/png;base64,{}'.format(encoded_image.decode()), style={"text-align": "center"})
        ])

if __name__ == '__main__':
    app.run_server(debug=True)

啓動應用程序,在瀏覽器中輸入控制檯的如下地址和端口號訪問該網頁:

@app.callback(
    Output('output-container-date-picker-single', 'children'),
    [Input('my-date-picker-single', 'date')])
def update_output(date):
    print("date", date)
    if date is not None:
        if date not in date_list:
            return html.Div([
               "數據不存在"
            ])
        fig = create_figure(date)
        return html.Div([
            dcc.Graph(figure=fig)
        ])


if __name__ == '__main__':
    app.run_server(debug=True) # 啓動應用程序

Plotly + Dash 框架

Plotly畫圖的函數中返回的fig可以直接放置在Dash組件庫中的Dcc.Graph中, Dash是plotly下面的一個產品,裏面的畫圖組件庫幾乎都是plotly提供的接口,所以plotly畫出的交互式圖可以直接在Dash中展示,無需轉換。觸發日期更新 plotly 畫圖事件代碼如下:

按之前同樣方式啓動應用程序,在瀏覽器中訪問網頁。

8. 結語

Matlplotlib 庫強大,功能多且全,但是出現的圖都是靜態圖,不便於交互式,對有些複雜的圖來說可能其庫裏面用法也比較複雜。對於這個榜單圖來說可能matplotlib畫圖更方便一些。

Plotly 庫是交互式圖表庫,圖形的種類也多,畫出的圖比較炫酷,鼠標點擊以及懸停可以看到更多的數據信息,還有各種氣泡圖,滑動slider動畫效果圖,且生成的圖片保存在html文件中,雖說有些功能比不上matplotlib全而強大,像這個榜單圖,沒有水平線hline或豎直線vline,雖有shape,但不能爲shapes添加圖例,但是這個庫也在慢慢發展,官方論壇community裏面也有許多人提出問題,各路大神也都在解決這些問題,相信之後plotly的功能會越來越全。

文中代碼及數據已上傳,地址: https://pan.baidu.com/s/1Uv_cursTr0cbTLB3D6ylIw 提取碼: jmch

----

獲取更多教程和案例,

歡迎搜索及關注:Crossin的編程教室

這裏還有更多精彩。一起學,走得遠

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章