Bokeh 股票相关性仪表盘实战:深入解析 stocks 示例与 Bokeh Server 应用开发
【免费下载链接】bokehInteractive Data Visualization in the browser, from Python项目地址: https://gitcode.com/GitHub_Trending/bo/bokeh
本文以 Bokeh 官方仓库中的stocks示例应用(examples/server/app/stocks)为主线,完整讲解如何用 Bokeh Server 从零构建一个可交互的股票对比仪表盘:包括启动命令、目录型应用的目录结构与加载机制、基于yfinance的行情数据加载、联动绘图与选择交互的实现,以及app_hooks.py生命周期钩子的底层原理。读完本文,你将掌握 Bokeh 服务器应用(Bokeh Application)的标准开发范式,并可以直接复用这套“数据准备 + 回调联动 + 布局组合”的模式搭建自己的数据分析看板。
一、stocks 示例是做什么的
stocks是 Bokeh 仓库中一个典型的Bokeh Server 应用(Bokeh Application),用于创建“一个简单的股票相关性仪表盘(a simple stocks correlation dashboard)”。该应用的定位在其源码 examples/server/app/stocks/main.py 的模块 docstring 中写得很清楚:
Choose stocks to compare in the drop down widgets, and make selections on the plots to update the summary and histograms accordingly.
即:用户通过下拉框选择两支要对比的股票,应用会在散点图上展示两支股票收益率的相关性;用户还可以在图上**框选(box select)**数据点,右侧的摘要统计(describe()输出)会随选择实时更新。整个应用由以下几部分交互构成:
- 两个
Select下拉框,用于选择股票(默认候选池为AAPL、GOOG、INTC、NVDA、MSFT); - 一张收益率相关性散点图(
corr),支持box_select框选; - 两条时间序列曲线图(
ts1、ts2),分别显示两支股票的收盘价,支持xbox_select横向框选; - 一个
PreText统计面板,实时展示所选股票的收盘价与收益率的描述性统计(describe())。
在 Bokeh 官方的 Server 应用总览文档 examples/server/app/README.md 中,stocks 被描述为 “Linked plots, summary statistics, and correlations for market data”,即“联动绘图、摘要统计与市场数据相关性”,这是学习 Bokeh 联动交互最合适的入门级示例之一。
二、运行方式:一条命令启动整个看板
根据 examples/server/app/stocks/README.md,启动该应用的命令非常简单:先进入其父目录examples/server/app,然后执行:
bokeh serve --show stocks其中:
bokeh serve是 Bokeh 自带的服务器命令,用于托管 Bokeh 应用;stocks是应用目录名(即examples/server/app/stocks/目录,Bokeh 会将其解析为一个目录型应用);--show表示启动成功后自动在浏览器中打开新标签页。
启动后,浏览器地址为:
http://localhost:5006/stocks端口5006是 Bokeh Server 的默认端口;/stocks是应用目录名对应的 URL 路径。关于bokeh serve的更多用法(如指定端口--port、--allow-websocket-origin等),可以执行bokeh serve --help查看。
注意:stocks 应用需要联网加载股票数据(见下文
app_hooks.py),并且依赖第三方库yfinance,运行前需要保证环境已安装yfinance(pip install yfinance)。
除了 stocks,examples/server/app目录下还提供了clustering、crossfilter、movies、ohlc、selection_histogram、spectrogram等大量同类 Bokeh Server 应用,运行方式完全一致(例如bokeh serve --show selection_histogram.py直接指定单脚本文件),适合对照学习。
三、目录型应用的结构与加载机制
stocks 是一个目录型应用(Directory-style app),其目录内只有三个文件:
examples/server/app/stocks/ ├── README.md # 应用说明与运行命令 ├── app_hooks.py # 服务端生命周期钩子(数据预加载) └── main.py # 应用主体:构建文档、绘图与回调这种目录结构是 Bokeh Server 的规范约定。从源码 src/bokeh/application/handlers/directory.py 可以看到,DirectoryHandler在初始化时会:
- 在目录中查找
main.py或main.ipynb,作为主应用脚本(两者同时存在时优先使用main.py,见 directory.py#L132-L142); - 查找目录下的
app_hooks.py或server_lifecycle.py,作为生命周期钩子模块;两者不能同时存在,否则会抛出ValueError(见 directory.py#L149-L162); - 若存在
__init__.py,则将其作为 Python 包运行,使main.py可以通过包名共享模块级状态。
也就是说,对 Bokeh 而言,一个目录只要有main.py,就可以被bokeh serve直接当作应用启动;app_hooks.py属于可选的“锦上添花”能力,用于在服务端生命周期事件中执行数据准备逻辑。
3.1 数据加载钩子:app_hooks.py
stocks 的 app_hooks.py 是理解“Bokeh 应用如何预加载数据”的最佳范例,全文如下:
import yfinance as yf TICKERS = ["AAPL", "GOOG", "INTC", "NVDA", "MSFT"] def on_server_loaded(server_context): print("\nLoading data from Yahoo finance...", end="") for ticker in TICKERS: df = yf.download(ticker, start="2020-09-01", end="2022-09-01") df["Returns"] = df["Close"].diff() setattr(server_context, ticker, df) print("DONE!\n")关键点解读:
on_server_loaded(server_context)是 Bokeh 定义的服务端生命周期回调,在服务器首次加载该应用时执行一次(不是每次会话都执行)。它的作用是把五支股票(AAPL、GOOG、INTC、NVDA、MSFT)在 2020-09-01 至 2022-09-01 期间的数据一次性下载到内存中,并通过setattr(server_context, ticker, df)把每个DataFrame挂到server_context上,供main.py直接读取。- 数据来源是 Yahoo Finance(通过
yfinance库),并且额外计算了一列日收益率Returns = Close.diff()(收盘价的一阶差分)。 - 这种“在
on_server_loaded中准备全局共享数据”的模式,避免了每个浏览器会话都重复下载同一批数据,属于 Bokeh Server 应用的标准优化手段。
3.2 生命周期钩子的底层原理
app_hooks.py之所以能被识别,是因为 Bokeh 在DirectoryHandler中专门查找该文件名,并交由ServerLifecycleHandler处理(见 directory.py#L150-L160)。而ServerLifecycleHandler在 src/bokeh/application/handlers/server_lifecycle.py 中实现:它会读取钩子模块的__dict__,提取其中定义的on_server_loaded、on_server_unloaded、on_session_created、on_session_destroyed四个可选回调(见 server_lifecycle.py#L83-L97),并用_check_callback校验其签名(on_server_loaded必须接收server_context参数)。
真正的调用时机在 src/bokeh/application/application.py#L195-L203:Application.on_server_loaded(server_context)会按顺序调用每个 handler的on_server_loaded。因此:
- 若你的应用有多个 handler(例如同时挂载多个目录应用),每个 handler 的钩子都会被执行;
on_server_unloaded在服务器干净退出前执行(需要注意:服务器常被信号直接杀死,该回调在实践中不一定可靠)。
stocks 示例只用了on_server_loaded一个钩子,把“下载五支股票数据”这件事与“构建界面”解耦,让main.py保持纯粹的可视化逻辑——这是目录型应用推荐的职责划分。
四、应用主体 main.py 逐段解析
main.py 是应用的核心,它完成“取数据 → 建图 → 挂回调 → 布局 → 提交文档”的完整流程。下面按逻辑分段解读。
4.1 全局状态与数据读取
from functools import lru_cache from bokeh.layouts import column, row from bokeh.models import ColumnDataSource, PreText, Select from bokeh.plotting import curdoc, figure DEFAULT_TICKERS = ["AAPL", "GOOG", "INTC", "NVDA", "MSFT"] SERVER_CONTEXT = curdoc().session_context.server_context def nix(val, lst): return [x for x in lst if x != val] @lru_cache def get_data(t1, t2): df1 = getattr(SERVER_CONTEXT, t1) df2 = getattr(SERVER_CONTEXT, t2) df1.columns = [f"{col[0]}_{col[1]}" if isinstance(col, tuple) and col[1] != "" else col[0] for col in df1.columns] df2.columns = [f"{col[0]}_{col[1]}" if isinstance(col, tuple) and col[1] != "" else col[0] for col in df2.columns] df1 = df1.rename(columns={"Close": f"Close_{t1}", "Returns": f"Returns_{t1}"}) df2 = df2.rename(columns={"Close": f"Close_{t2}", "Returns": f"Returns_{t2}"}) data = df1.join(df2, how="outer").dropna() data["t1"] = data[f"Close_{t1}"] data["t2"] = data[f"Close_{t2}"] data["t1_returns"] = data[f"Returns_{t1}"] data["t2_returns"] = data[f"Returns_{t2}"] return data这里有三处值得注意的实现细节:
- 通过
session_context.server_context拿到全局数据:SERVER_CONTEXT = curdoc().session_context.server_context正是读取app_hooks.py中挂载数据的关键句柄。main.py每次执行都通过getattr(SERVER_CONTEXT, ticker)取回对应股票的DataFrame,实现“数据加载一次、多处复用”。 - 扁平化 MultiIndex 列名:
yfinance下载的数据列名是(字段, 股票代码)形式的元组(MultiIndex),所以这里把元组列名拼接成字段_代码字符串(如Close_AAPL),再统一重命名为Close_{t1}、Returns_{t1}等带股票后缀的列,最后用join(how="outer")按日期外连接、dropna()丢弃缺失行。 @lru_cache缓存:get_data(t1, t2)用最近最少使用缓存缓存计算结果,当用户反复切换股票组合时,相同组合的 DataFrame 无需重复拼接,属于典型的性能优化手段。
4.2 两个数据源与三张图
source = ColumnDataSource(data=dict(date=[], t1=[], t2=[], t1_returns=[], t2_returns=[])) source_static = ColumnDataSource(data=dict(date=[], t1=[], t2=[], t1_returns=[], t2_returns=[])) corr = figure(width=370, height=350, min_border_left=60, tools="pan,wheel_zoom,box_select,reset", active_drag="box_select") corr.scatter("t1_returns", "t2_returns", size=3, source=source, selection_color="orange", alpha=0.8, nonselection_alpha=0.1, selection_alpha=0.5) ts1 = figure(width=900, height=200, x_axis_type="datetime", tools="pan,wheel_zoom,xbox_select,reset", active_drag="xbox_select") ts1.line("Date", "t1", source=source_static) ts1.scatter("Date", "t1", size=3, source=source, color=None, selection_color="orange") ts2 = figure(width=900, height=200, x_axis_type="datetime", tools="pan,wheel_zoom,xbox_select,reset", active_drag="xbox_select") ts2.line("Date", "t2", source=source_static) ts2.scatter("Date", "t2", source=source, color=None, selection_color="orange") ts2.x_range = ts1.x_range这段代码展示了 Bokeh 联动交互的经典设计:
- 两个
ColumnDataSource分工明确:source用于散点层(承载选择状态),source_static用于线层(静态底图)。选择交互发生在source上,而底图线不参与选择,因此框选不会改变线的外观。 corr相关性散点图:以t1_returns为 x 轴、t2_returns为 y 轴绘制散点,active_drag="box_select"将默认拖拽手势设为框选;通过selection_color="orange"、selection_alpha=0.5、nonselection_alpha=0.1区分选中/未选中点,这是 Bokeh 基于ColumnDataSource选择状态的典型视觉反馈。ts1/ts2时间序列图:x_axis_type="datetime"让 x 轴按时间刻度渲染;ts2.x_range = ts1.x_range将两张图的 x 轴范围共享,实现缩放/平移时两张时序图联动——这是 Bokeh 中最常用的跨图联动手段之一。
4.3 下拉框与回调联动
stats = PreText(text="", width=500) ticker1 = Select(value="AAPL", options=nix("GOOG", DEFAULT_TICKERS)) def ticker1_change(attrname, old, new): ticker2.options = nix(new, DEFAULT_TICKERS) update() ticker1.on_change("value", ticker1_change) ticker2 = Select(value="GOOG", options=nix("AAPL", DEFAULT_TICKERS)) def ticker2_change(attrname, old, new): ticker1.options = nix(new, DEFAULT_TICKERS) update() ticker2.on_change("value", ticker2_change)两个Select下拉框通过on_change("value", callback)注册回调,实现互斥选择:当用户在ticker1中选择了一支股票,ticker1_change会立刻用nix(即“从列表中剔除该项”)把该股票从ticker2的候选项中移除,保证两支股票不会相同,然后调用update()刷新全图。回调签名(attrname, old, new)是 Bokeh 属性回调的固定形式,分别表示变化的属性名、旧值、新值。
4.4 更新与选择回调
def update_stats(data, t1, t2): stats.text = str(data[[f"Close_{t1}", f"Close_{t2}", f"Returns_{t1}", f"Returns_{t1}"]].describe()) def update(selected=None): t1, t2 = ticker1.value, ticker2.value data = get_data(t1, t2) source.data = data source.selected.indices = [] source_static.data = data corr.title.text = f"{t1} returns vs. {t2} returns" ts1.title.text, ts2.title.text = t1, t2 update_stats(data, t1, t2) def selection_change(attrname, old, new): t1, t2 = ticker1.value, ticker2.value data = get_data(t1, t2) if selected := source.selected.indices: data = data.iloc[selected, :] update_stats(data, t1, t2) source.selected.on_change("indices", selection_change)update()是核心刷新函数:切换股票后重新取数、把新数据一次性写入source和source_static、清空旧选择(source.selected.indices = [])、更新三张图的标题,并刷新统计文本。注意source.data = data是把整个DataFrame直接赋给ColumnDataSource,Bokeh 会自动按列名匹配列。selection_change监听source.selected.indices(即被框选中的行索引)。if selected := source.selected.indices:使用了海象运算符:当存在选中项时,只对选中行做统计(data.iloc[selected, :]),实现“框选即看子集统计”。- 一个可以留意的细节:从源码看,
update_stats中统计列列表的第四项写的是Returns_{t1}而不是Returns_{t2}(见 main.py#L78-L79)。这看起来像示例源码中的一处笔误(重复引用了第一支股票的收益率列),但不影响应用整体运行;读者在自行复刻时可以改为Returns_{t2}以统计两支股票的收益率。
4.5 布局与提交文档
update() widgets = column(row(ticker1, ticker2), stats) main_row = row(corr, widgets) series = column(ts1, ts2) curdoc().add_root(column(main_row, series)) curdoc().title = "Stocks"最后一步是布局与文档提交:
- 用
row/column组合布局:上方一行是“散点图 + 右侧控件(两个下拉框和统计面板)”,下方一列是两张时序图; - 脚本末尾调用
curdoc().add_root(...)把布局树挂到当前文档,curdoc().title = "Stocks"设置浏览器标签页标题; update()在末尾被显式调用一次,保证页面首次加载时立即渲染出默认股票(AAPL vs GOOG)的完整数据。
这也解释了 Bokeh 目录型应用的本质:main.py每次新会话都会执行一遍,向curdoc()提交文档内容;而app_hooks.py中的on_server_loaded只执行一次,用于准备跨会话共享的数据。
五、事件驱动模型小结:一次完整的交互链路
综合main.py与app_hooks.py,一次典型交互(用户在ticker1中把股票从 AAPL 换成 NVDA)的完整链路如下:
- Bokeh Server 启动,
DirectoryHandler加载app_hooks.py(见 directory.py#L150-L162); Application.on_server_loaded触发on_server_loaded(server_context),五支股票数据被下载并挂到server_context(application.py#L195-L203);- 浏览器打开
http://localhost:5006/stocks,新会话执行main.py,get_data("AAPL", "GOOG")经lru_cache取数并构建界面; - 用户在下拉框选择 NVDA,
ticker1_change回调触发:ticker2.options剔除 NVDA,随后update()重新取数(此时get_data("NVDA", "GOOG")命中缓存则直接返回)、刷新三张图与统计; - 用户在散点图上框选数据点,
source.selected.indices变化触发selection_change,统计面板切换为选中子集的describe()输出。
整条链路完全由 Bokeh Server 的事件回调机制驱动:Python 回调运行在服务器端,界面状态的变更通过 WebSocket 同步到浏览器端渲染,无需编写任何前端 JavaScript。
六、如何在此基础上扩展
stocks 是一个极具可扩展性的模板,读者可以基于它快速搭建自己的看板:
- 更换数据源:把 app_hooks.py 中
yf.download(...)替换为本地 CSV、数据库或自建接口,只要最终在server_context上挂载“含日期、收盘价列的DataFrame”即可; - 增加股票池:修改
DEFAULT_TICKERS(main.py)与TICKERS(app_hooks.py)为同一组代码即可扩充候选列表; - 增加统计维度:在
get_data中追加计算列(如滚动均值、涨跌幅),并在update_stats的describe()之外补充自定义统计; - 参考同目录示例:
examples/server/app下的 selection_histogram.py 演示了“选中/未选中点分别绘制直方图”的进阶选择交互,ohlc 演示了流式更新(streaming API),非常适合与 stocks 对照学习。
七、总结
stocks虽然只是一个“简单的股票相关性仪表盘”,但它浓缩了 Bokeh Server 应用开发的全部核心知识点:目录型应用的组织方式(main.py+ 可选app_hooks.py)、服务端生命周期钩子(on_server_loaded)与跨会话数据共享(server_context)、ColumnDataSource驱动的选择状态可视化、on_change属性回调与selected.indices选择回调、跨图联动(共享x_range)以及row/column布局与curdoc()文档提交。掌握了这份示例,你就掌握了用纯 Python 构建交互式数据看板的核心方法——这正是 Bokeh “从 Python 到浏览器交互式可视化”理念的最佳入门教材。
【免费下载链接】bokehInteractive Data Visualization in the browser, from Python项目地址: https://gitcode.com/GitHub_Trending/bo/bokeh
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考