Crossin的编程教室

标题: [数据可视化]绘制持仓榜单的“棒棒糖图” [打印本页]

作者: 江水滔滔    时间: 2020-7-30 17:43
标题: [数据可视化]绘制持仓榜单的“棒棒糖图”
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 装:
  1. pip install plotly dash
复制代码
或者也可以用 conda 进行安装。
5. 数据格式和数据处理
测试数据来自东方财富网,用 csv 文件格式保存。

数据的格式如下,header 是日期为第一列,第3列往后为期货公司名字。表格中的负数是上面图中蓝色的空仓,正数是红色的多仓。绘图时,从表格中取出某一日期的一行记录,将持仓数目排序,把对应的数据存入列表中,之后进行画图。

首先对数据进行清洗和处理, pandas读取数据,这里需要去除 000905_SH 列,以及删除全0行。代码如下:
  1. excel_pd = pd.read_excel('data/IC期货商历史数据(1).xlsx', index_col='日期')
  2. # 去空
  3. excel_pd.dropna()
  4. # 去除000905_SH列
  5. excel_pd = excel_pd.drop(labels='000905_SH', axis=1)
  6. # 去0行
  7. excel_pd = excel_pd[~(excel_pd == 0).all(axis=1)]
  8. # 取出时间列表,获取最大日期和最小日期,为日历选项做判断
  9. date_list = excel_pd.index.values.tolist()
  10. min_date = min(date_list)
  11. max_date = max(date_list)
复制代码
接下来我们需要根据输入的日期来筛选某一行记录,分别将持空仓期货公司和持多仓期货公司取出,剔除持仓数为0的期货公司。代码如下:
  1. def get_data_via_date_from_excel(date):
  2.     # 筛选日期
  3.     sheet1_data = excel_pd.loc[date]
  4.     # 去除列值为0
  5.     sheet1_data = sheet1_data[sheet1_data != 0]
  6.     # 排序 从小到大
  7.     sheet1_data = sheet1_data.sort_values()
  8.     # 空仓
  9.     short_hold = sheet1_data[sheet1_data < 0]
  10.     # 多仓
  11.     long_hold = sheet1_data[sheet1_data >= 0].sort_values(ascending=False)
  12.     return short_hold, long_hold
复制代码
6. 画图Matplotlib画图
创建一张画布figure和ax画图层,用ax.hlines分别画空仓水平线和多仓水平线。用ax.scatter画左右两边线的散点,使用菱形marker。使用plt.text分别画线两端的标注期货公司和持仓数。plt.annotate画排名标注,分别设置颜色和字体大小。

但这个效果是反的,我们是希望排名最前面的在上,排名最后面的下。这时我们可以设置y轴反置一下ax.invert_yaxis()。添加图例和标题以及设置坐标轴不可见,得到最终效果:

核心代码如下:
  1. def draw_lollipop_graph(short_hold, long_hold, date):
  2.     # sheet_major.index.values.tolist()
  3.     fig, ax = plt.subplots(figsize=(10, 8))
  4.     # 空仓水平线
  5.     ax.hlines(y=[i for i in range(len(short_hold))], xmin=list(short_hold), xmax=[0] * len(short_hold.index), color='#1a68cc', label='空')
  6.     # 多仓水平线
  7.     ax.hlines(y=[i for i in range(len(long_hold))], xmax=list(long_hold), xmin=[0] * len(long_hold.index), color='red', label='多')
  8.     # 画散点
  9.     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设置该点覆盖线
  10.     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设置该点覆盖线
  11.     # 画线两端标注图
  12.     for x, y, label in zip(list(short_hold), range(len(short_hold)), short_hold.index):
  13.         plt.text(x=x, y=y, s=label+'({}) '.format(abs(x)), horizontalalignment='right', verticalalignment='center', fontsize=10)
  14.     for x, y, label in zip(list(long_hold), range(len(long_hold)), long_hold.index):
  15.         plt.text(x=x, y=y, s=' '+label+'({})'.format(abs(x)), horizontalalignment='left', verticalalignment='center', fontsize=10)
  16.     # 设置排名
  17.     size = [17, 16, 15] + [8 for i in range(max(len(short_hold), len(long_hold))-3)]
  18.     color = ['#b91818', '#e26012', '#dd9f10'] + ['#404040' for i in range(max(len(short_hold), len(long_hold))-3)]
  19.     for i, s, c in zip(range(max(len(short_hold), len(long_hold))+1), size, color):
  20.         plt.annotate(s=i+1, xy=(0, i), fontsize=s, ma='center', ha='center', color=c)
  21.     # 坐标轴y反置
  22.     ax.invert_yaxis()
  23.     # 坐标轴不可见
  24.     ax.set_xticks([])
  25.     ax.set_yticks([])
  26.     ax.spines['top'].set_visible(False)  # 去上边框
  27.     ax.spines['bottom'].set_visible(False)  # 去下边框
  28.     ax.spines['left'].set_visible(False)  # 去左边框
  29.     ax.spines['right'].set_visible(False)  # 去右边框
  30.     # 设置title
  31.     ax.set_title('黄金持仓龙虎榜单({})'.format(date), position=(0.7, 1.07), fontdict=dict(fontsize=20, color='black'))
  32.     # 自动获取ax图例句柄及其标签
  33.     handles, labels = ax.get_legend_handles_labels()
  34.     plt.legend(handles=handles, ncol=2, bbox_to_anchor=(0.75, 1.05), labels=labels, edgecolor='white', fontsize=10)
  35.     # 保存fig
  36.     image_filename = "lollipop_rank.png"
  37.     plt.savefig(image_filename)
  38.     encoded_image = base64.b64encode(open(image_filename, 'rb').read())
  39.     # plt.show()
  40.     return encoded_image
复制代码
Plotly画图
1) Figure 是一张画布,跟 matplotlib 的 figure 是一样,数据是字典形式,创建代码如下:
  1. import plotly.graph_objects as go
  2. fig = go.Figure() # 创建空画布
  3. fig.show()
复制代码
2) Traces 轨迹,即所有的图表层都是在这里画的,轨迹的类型都是由type指定的(例如"bar","scatter","contour"等等)。轨迹是列表,创建代码如下:
  1. fig = go.Figure(data=[trace1, trace2]) # 定义figure时加上轨迹数据
  2. Figure.add_traces(data[, rows, cols, …])   # 或者先定义一张空的画布,再添加轨迹
  3. Figure.update_traces([patch, selector, row, …])  # 更新轨迹
  4. # 可运行代码
  5. import plotly.graph_objects as go
  6. trace = [go.Scatter(  # 创建trace
  7.     x=[0, 1, 2],
  8.     y=[2, 2, 2],
  9.     mode="markers",
  10.     marker=dict(color="#1a68cc", size=20),
  11. )]
  12. fig = go.Figure(data=trace)
  13. fig.show()
复制代码
3) Layout 层,设置标题,排版,页边距,轴,注释,形状,legend图例等等。布局配置选项适用于整个图形。
  1. import plotly.graph_objects as go
  2. trace = [go.Scatter(
  3.     x=[0, 1, 2],
  4.     y=[2, 2, 2],
  5.     mode="markers",
  6.     marker=dict(color="#1a68cc", size=20),
  7. )]
  8. # 创建layout,添加标题
  9. layout = go.Layout(
  10.         title=go.layout.Title(text="Converting Graph Objects To Dictionaries and JSON")
  11.     )
  12. fig = go.Figure(data=trace, layout=layout)
  13. fig.show()
  14. 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就用持仓列表的下标表示。
  1. # 空仓水平线
  2. short_shapes = [{'type': 'line',
  3.                  'yref': 'y1',
  4.                  'y0': k,
  5.                  'y1': k,
  6.                  'xref': 'x1',
  7.                  'x0': 0,
  8.                  'x1': i,
  9.                  'layer': 'below',
  10.                  'line': dict(
  11.                      color="#1a68cc",
  12.                  ),
  13.                  } for i, k in zip(short_hold, range(len(short_hold)))]
  14. # 多仓水平线
  15. long_shapes = [{'type': 'line',
  16.                 'yref': 'y1',
  17.                 'y0': k,
  18.                 'y1': k,
  19.                 'xref': 'x1',
  20.                 'x0': j,
  21.                 'x1': 0,
  22.                 'layer': 'below',
  23.                 'line': dict(
  24.                   color="red",
  25.                  )
  26.                 } for j, k in zip(long_hold, range(len(long_hold)))]
复制代码
画图2:线两端散点和标注

用scatter画左右两边线的散点,使用菱形marker并且scatter中的text可以标注线两端的标注期货公司和持仓数,注意持仓数都是正数。
  1. # 画散点
  2. fig.add_trace(go.Scatter(
  3.     x=short_hold,
  4.     y=[i for i in range(len(short_hold))],
  5.     mode='markers+text',
  6.     marker=dict(color="#1a68cc", symbol='diamond-open'),
  7.     text=[label + '(' + str(abs(i)) + ') ' for label, i in zip(short_hold.index, short_hold)],   # 散点两端的期货公司标注和持仓数
  8.     textposition='middle left', # 标注文字的位置
  9.     showlegend=False            # 该轨迹不显示图例legend
  10. ))
  11. fig.add_trace(go.Scatter(
  12.     x=long_hold,
  13.     y=[i for i in range(len(long_hold))],
  14.     mode='markers+text',
  15.     text=[' ' + label + '(' + str(abs(i)) + ')' for label, i in zip(long_hold.index, long_hold)],  # 散点两端的期货公司标注和持仓数
  16.     marker=dict(color='red', symbol='diamond-open'),
  17.     textposition='middle right',  # 标注文字的位置
  18.     showlegend=False           # 该轨迹不显示图例legend
  19. ))
复制代码
画图3:排名标注

继续用scatter只显示text来画排名标注,分别设置颜色和字体大小。
  1. # 线上的排名顺序
  2. fig.add_trace(go.Scatter(
  3.     x=[0]*max(len(short_hold), len(long_hold)),
  4.     y=[i for i in range(max(len(short_hold), len(long_hold)))],
  5.     mode='text',
  6.     text=[str(i+1) for i in range(max(len(short_hold), len(long_hold)))],  # 排名从1开始
  7.     textfont=dict(color=['#b91818', '#e26012', '#dd9f10'] + ['#404040' for i in range(max(len(short_hold), len(long_hold)) - 3)],
  8.                   size=[17, 16, 15] + [10 for i in range(max(len(short_hold), len(long_hold)) - 3)],
  9.                   family="Open Sans"),
  10.     textposition='top center',
  11.     showlegend=False
  12. ))
复制代码
画图4:图例

由于plotly shapes不是轨迹,只是layout中的一部分,所以不能添加legend,而上面的散点scatter虽是轨迹,但是mode =markers+text 使得legend中多出了text文字,如下图,而且目前版本的plotly不具备自定义legend去除text功能。

所以我们需要自己添加2条轨迹来显示legend图例,代码如下:
  1. # 加上这条trace只是为了显示legend图例,因为scatter图例中显示的text在plotly现有的版本基础上去除不了
  2. fig.add_trace(go.Scatter(
  3.     x=[0, long_hold[0]],
  4.     y=[range(len(long_hold))[0], range(len(long_hold))[0]],
  5.     mode='lines',
  6.     marker=dict(color='red'),
  7.     name='多'
  8. ))
  9. fig.add_trace(go.Scatter(
  10.     x=[0, short_hold[0]],
  11.     y=[range(len(short_hold))[0], range(len(short_hold))[0]],
  12.     mode='lines',
  13.     marker=dict(color='#1a68cc'),
  14.     name='空'
  15. ))
复制代码
设置y轴反置 autorange='reversed' 可让排名最前面的在上,排名最后面的在下,之后设置图里位置,添加标题以及设置坐标轴不可见, 代码如下:
  1. # X, Y坐标轴不可见
  2. fig.update_xaxes(
  3.     showticklabels=False,
  4.     showgrid=False,
  5.     zeroline=False,)
  6. fig.update_yaxes(
  7.     showticklabels=False,
  8.     showgrid=False,
  9.     zeroline=False,
  10.     autorange='reversed'  # Y 轴倒置)
  11. fig.update_layout(
  12.     shapes=short_shapes+long_shapes,  # 添加水平线
  13.     width=2100,
  14.     height=900,
  15.     legend=dict(x=0.62, y=1.02, orientation='h'),
  16.     template="plotly_white",
  17.     title=dict(
  18.         text='黄金持仓龙虎榜单(' + date + ')',
  19.         y=0.95,
  20.         x=0.65,
  21.         xanchor='center',
  22.         yanchor='top',
  23.         font=dict(family="Open Sans", size=30)
  24.     )
  25. )
复制代码

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函数来捕捉日期更新从而画图事件。
  1. import dash
  2. import dash_html_components as html
  3. import dash_core_components as dcc
  4. external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']
  5. app = dash.Dash(__name__, external_stylesheets=external_stylesheets)
  6. app.layout = html.Div([
  7.     html.Div(dcc.DatePickerSingle(
  8.         id='my-date-picker-single',
  9.         min_date_allowed=min_date,  # 日历最小日期
  10.         max_date_allowed=max_date,  # 日历最大日期
  11.         date=max_date   # dash 程序初始化日历的默认值日期
  12.     ), style={"margin-left": "300px"}),
  13.     html.Div(id='output-container-date-picker-single', style={"text-align": "center"})
  14. ])
复制代码
Matplotlib + Dash 框架
之前我们用matplotlib画好的榜单图已经编码保存好,注意这里画的图是静态图,触发日期更新画matplotlib画图事件代码如下:
  1. @app.callback(
  2.     Output('output-container-date-picker-single', 'children'),
  3.     [Input('my-date-picker-single', 'date')])
  4. def update_output(date):
  5.     print("date", date)
  6.     if date is not None:
  7.         if date not in date_list:
  8.             return html.Div([
  9.                "数据不存在"
  10.             ])
  11.         encoded_image = create_figure(date)
  12.         return html.Div([
  13.             html.Img(src='data:image/png;base64,{}'.format(encoded_image.decode()), style={"text-align": "center"})
  14.         ])
  15. if __name__ == '__main__':
  16.     app.run_server(debug=True)
复制代码
启动应用程序,在浏览器中输入控制台的如下地址和端口号访问该网页:
  1. @app.callback(
  2.     Output('output-container-date-picker-single', 'children'),
  3.     [Input('my-date-picker-single', 'date')])
  4. def update_output(date):
  5.     print("date", date)
  6.     if date is not None:
  7.         if date not in date_list:
  8.             return html.Div([
  9.                "数据不存在"
  10.             ])
  11.         fig = create_figure(date)
  12.         return html.Div([
  13.             dcc.Graph(figure=fig)
  14.         ])
  15. if __name__ == '__main__':
  16.     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的编程教室

这里还有更多精彩。一起学,走得远






欢迎光临 Crossin的编程教室 (https://bbs.crossincode.com/) Powered by Discuz! X2.5