用Streamlit搭建财务指标选股回测可视化平台
2026/9/10 0:41:34 网站建设 项目流程

做量化投研,回测是绕不开的一环。这个项目用Streamlit搭了一套基于财务指标筛选股票、再进行投资组合回测的轻量级可视化平台,把从数据清洗、因子筛选到策略回测、绩效展示的整个流程串了起来。我最初只是想用Streamlit做一个简单的财务数据看板,结果发现指标选股和回测结合之后,整个工具的价值翻了不止一倍——它不再是个“数据展示器”,而是一个能验证投资思路的决策辅助系统。

这篇文章会把项目的整体设计、技术选型、核心代码和踩过的坑完整写出来,适合有Python基础、想把Streamlit引入金融数据分析场景的人参考。我用的是最朴素的技术栈:Streamlit做了前端交互层,pandas处理数据,plotly画图。这套组合的好处是开发速度快、迭代灵活,不需要单独写前端页面。整个平台的核心逻辑是:拉取财务指标数据,通过条件筛选和打分机制构建股票池,然后对股票池做历史回测,最终输出收益曲线、最大回撤、夏普比率等绩效指标。

1. 项目整体设计与思路拆解

1.1 核心需求解析

先说需求来源。做股票分析最常遇到的一个问题是:财务指标好,到底能不能赚钱?比如你筛选出ROE大于15%、市盈率小于20倍的股票,持有三个月,收益到底怎么样?这个问题在网上可以查到各种说法,但多数是观点而不是结论,没有数据支撑,也不敢直接拿来指导操作。

要回答这个问题,需要一套可复现的验证流程:选定期限、定期调仓、统一计算收益和风险。这个流程如果全用代码在Jupyter Notebook里跑,只有自己看得到,交互性也比较弱。我想把数据筛选、参数调整、结果展示做成一个可视化工具,让分析过程更直观,也能让不太会写代码的人尝试调整参数。这就是这个项目最原始的出发点。

用Streamlit实现这套系统有几个天然优势:纯Python开发,不用写HTML、CSS和JavaScript;自带交互组件,滑杆、下拉框、表格、图表都能直接嵌入页面;数据改动后页面自动刷新,调试方便;最终部署也是单文件,跑一个命令就能对外提供服务。

1.2 技术选型思路

技术方案我对比过三种:Flask + 前端模板、Jupyter Notebook、Streamlit。

Flask方案最灵活,但需要自己处理前端。一个回测系统至少需要参数设置区、结果图表区、持仓明细表、指标卡片这几个模块,用Flask做这些界面交互需要写大量前端代码,一人开发多天后端主要时间反而花在了HTML和JavaScript调试上,实属本末倒置。Jupyter Notebook适合做探索性分析,但交互组件弱,分享也麻烦,总不能每次都用邮件发个ipynb文件。Streamlit交互组件足够丰富,能满足数据分析工具的大部分需求,短板在于复杂页面布局不够自由,但对这种单页分析平台来说完全够用。

数据可视化选型同样对比过。matplotlib适合论文级静态图,交互性弱;pyecharts好看但数据处理接口有一定学习成本;plotly与Streamlit有原生交互支持,鼠标悬停、缩放、框选这些操作体验接近商业软件。回测结果需要频繁查看不同时间段的缩细节点,plotly的交互能力是刚需,所以最终选它画图。

1.3 整体架构设计

系统设计了四个模块:

数据层负责从数据源拉取财务指标和行情数据,做清洗和预处理。使用akshare作为免费数据源,通过缓存机制避免重复请求。策略层负责指标计算和选股逻辑,支持条件筛选和打分排序两种模式。回测引擎层是核心,负责按调仓周期执行模拟买卖,计算持仓权重、交易成本和净值曲线。展示层用Streamlit组件构建交互界面,呈现回测结果和绩效指标。

四个模块的调用关系是单向的:展示层调用策略层和回测引擎,回测引擎调用策略层,策略层调用数据层。这样设计的好处是后续替换数据源、修改选股逻辑、增加更复杂的回测规则,都不会相互牵连。我在最初版本里把数据获取和回测逻辑写在同一个函数里,结果改一个数据源就要动回测代码,重构之后才舒服。

设计架构时还考虑了一个重要问题:回测失败不能影响页面加载。akshare的数据接口偶发超时,如果请求失败时整个页面崩溃,用户体验很差。所以我在每层都加了异常捕获和兜底逻辑,失败时返回空数据并给出提示,而不是抛异常中断整个应用。

2. 环境准备与数据工程细节

2.1 开发环境搭建

开发环境推荐Python 3.9及以上版本。我实际用的是3.10,Streamlit对3.10支持很稳定。项目依赖以下主要库:

pip install streamlit pandas numpy plotly akshare

各版本组合我试过不少,目前最稳的是streamlit 1.30以上版本配合pandas 2.x。版本太老容易遇到plotly图表不显示的问题,升级后解决了。

创建虚拟环境是必须的。我习惯用venv:

python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate

如果你之前没有用过akshare,建议先跑一遍基础接口确认数据源正常,避免后续排错时分不清是代码问题还是数据源问题。跑通了再进下一步。

2.2 数据源选型与数据清洗

免费行情和财务数据源我对比过几个:akshare、tushare、baostock。tushare的pro接口数据质量高,但很多接口需要积分,积分要靠贡献或付费获得;baostock免费且稳定,但没有财报科目数据,做财务指标选股受限;akshare接口覆盖广、完全免费,缺点是接口变动较频繁,偶尔会失效。

财务数据我用akshare的stock_financial_abstract接口,可以获取净资产收益率、毛利率、净利率、资产负债率等关键指标。行情数据用stock_zh_a_hist接口,复权方式建议选前复权,这样在回测时计算收益率更接近真实可交易的价格。

数据清洗是这个项目里最容易被低估的工作。我最初拿到数据直接跑策略,结果回测曲线有一段突然暴涨,查了才发现是分红送股没有复权处理。财务数据方面,不同股票的财报发布日期不一样,数据对齐时如果不注意会导致前视偏差。我的处理方式是先用截至调仓日已披露的季报数据,用ffill方法顺延到下一个调仓日,即年报和季报发布之后的第一个调仓日才能用到这些数据。

2.3 财务指标预处理方法

财务指标原始数据有几个常见问题需要专门处理。

缺失值处理:很多股票在某些季度会缺少某一项指标。直接丢弃整行会损失样本,我采用的方法是:核心指标(ROE、PE)缺失时剔除该股票,次要指标缺失时用行业中位数填充。

异常值过滤:PE为负的公司说明亏损,我默认剔除;ROE超过100%的,多数是数据错误或一次性收益,也用分位数截取。具体做法是对每个指标做1%和99%百分位的缩尾处理,超过边界的值被拉回边界。

对齐处理:财务数据是季度频次,行情数据是日频次。我统一将财务数据向下重采样到日频,每个交易日都知道当时最新的财务指标数值是多少。这样保证了策略在历史回测时可以精确模拟当时的可用信息。

处理完的数据结构大概是这样的表格:

字段类型说明
trade_datedatetime交易日期
codestring股票代码
roefloat净资产收益率
pefloat市盈率(TTM)
pbfloat市净率
total_mvfloat总市值(元)

这个预处理流程写成一个独立的模块,后续策略版本迭代不用重复处理数据。

3. 核心功能实现:财务指标选股与回测引擎

3.1 指标筛选与打分逻辑

选股逻辑用了两种模式:条件筛选和打分排序。

条件筛选模式比较简单,就是按用户设定的阈值过滤。比如筛选条件为“PE在0到30之间、PB在0到5之间、ROE大于15%”,每个调仓日从全市场股票池里挑出满足条件的股票,等权买入。这个模式适合有明确的直觉认知、想快速验证某个策略是否有效的情况。

打分排序模式更灵活。对每个选中的财务指标,按照当前值的横截面分位数打分。ROE这类越高越好的指标正序打分,PE、PB这类越低越好的指标倒序打分。最后将各个指标的分数等权相加,得到一个综合得分,按得分从高到低取前N只股票买入。这个模式适合做多因子选股。

我当时试着用ROE(正序)+ PE(倒序)+ PB(倒序)三个指标等权打分,选取前20只股票做月度调仓回测,回测区间2018年到2023年。效果比单指标筛选稳定很多。打分法的核心思想是承认单指标有局限性,通过组合方式平滑掉单一指标的噪声。

打分归一化我用了百分位排名方式:

# 将指标列转换为0-100之间的分数 def factor_score(series, high_good=True): rank_pct = series.rank(pct=True) if not high_good: rank_pct = 1 - rank_pct return rank_pct * 100

这样各指标之间的量纲差异被消除,可以直接相加比较。

3.2 回测引擎设计

回测引擎是整个平台的核心模块。我用的是向量化计算和逐日循环结合的方式:调仓日重新计算目标持仓权重,非调仓日按前一日权重计算组合净值变化。

关键的调仓逻辑如下:

  • 设定调仓周期:支持月度调仓和季度调仓
  • 每个调仓日,用当前时间点可见的财务数据选股
  • 目标权重默认等权分配
  • 实际持仓与目标持仓不一致时,计算交易量,扣除手续费和滑点

手续费按双边万分之二点五设置,滑点按0.5%设置。滑点参数在回测里特别重要。我在最初版本把滑点设为0,回测年化收益接近25%,当时觉得发现了无敌策略,后来加上了滑点,收益直接降到15%。这个落差让我意识到,回测中忽略交易成本会严重高估策略表现。

净值曲线计算采用日度循环:

# 简化版回测核心循环 def run_backtest(daily_data, rebalance_freq='ME', initial_capital=1000000): cash = initial_capital positions = {} # code -> shares nav = [] # 每日净值 for date, day_df in daily_data.groupby(pd.Grouper(freq='D')): # 如果是调仓日,执行再平衡 if is_rebalance_day(date, rebalance_freq): target_weights = select_stocks(day_df) cash, positions = rebalance(cash, positions, target_weights, day_df) # 计算当日组合市值 total_value = cash + sum(shares * get_price(code, date) for code, shares in positions.items()) nav.append({'date': date, 'nav': total_value / initial_capital}) return pd.DataFrame(nav)

这个循环的关键在于is_rebalance_day的判断逻辑。月度调仓我取了每个月的最后一个交易日,交易日历直接用了行情数据里的自然日期去重结果。季度调仓则取3、6、9、12月的最后一个交易日。

3.3 绩效指标计算与参数详解

回测做完以后,光看一条净值曲线不够,必须用量化指标说话。我计算了年化收益率、年化波动率、最大回撤、夏普比率、卡玛比率这五个核心指标。

年化收益率采用几何年化算法:

$$年化收益 = (期末净值 / 期初净值)^{252/交易天数} - 1$$

不要用算术平均去年化,会造成较大偏差。比如月度收益率的算术平均乘以12会高估实际年化收益,因为忽略了收益率的复利效应。

最大回撤是回测系统中最直观的风险指标。计算方法是:

def max_drawdown(nav_series): peak = nav_series.cummax() drawdown = (nav_series - peak) / peak return drawdown.min() # 负值

最大回撤的含义是从任何一个历史高点买入后,最坏情况亏损多少。我在展示面板用红色高亮显示这个数字,因为它是衡量策略风险承受能力的重要参考。

夏普比率 = (年化收益 - 无风险利率) / 年化波动率。无风险利率我默认取2%,也可以用国债收益率替代。年化波动率用日收益率标准差乘以根号252得到。夏普比率大于1说明策略承担单位风险获得了超额回报,大于2就很优秀了。

这部分指标计算代码写进了performance.py模块,页面直接调用,没有和前端逻辑混在一起。

4. Streamlit界面搭建与交互设计

4.1 页面布局与模块划分

Streamlit的页面布局通过st.set_page_config设置。我用的是wide模式,把页面内容分成三个区域:侧边栏放参数控制,主区域上方放绩效指标卡片,下方放净值曲线和回撤曲线,再往下是调仓记录和持仓明细。

侧边栏是整个平台的交互控制中心,包含以下组件:

  • 回测区间:st.date_input选择起止日期
  • 财务指标条件筛选:st.multiselect选择参与选股的指标
  • 阈值设定:st.slider调整各指标的范围
  • 调仓频率:st.radio选择月度或季度调仓
  • 持仓数量:st.slider选择打分排序后买入的股票数量
  • 手续费与滑点:st.number_input设定

用户调整完参数,点击侧边栏底部的“运行回测”按钮,页面主体部分才更新。这个交互设计解决了每次拖动滑杆页面反复刷新、耗时过长的问题。具体实现是用st.session_state记录回测按钮的点击状态:

if st.sidebar.button('运行回测'): st.session_state.run_clicked = True if st.session_state.get('run_clicked'): # 执行回测并渲染结果

主区域的绩效指标用st.metric组件展示,一行放五个指标,美观直观。指标之间的间距和颜色通过st.columns布局控制,回撤数值如果超预期会显示为红色。

4.2 图表可视化与关键代码实现

图表全部用plotly实现,配合Streamlit的st.plotly_chart组件渲染。

净值曲线是最核心的图。我画了两条线:策略净值(红色)和沪深300指数净值(灰色虚线),做基准对比。这样能在图中直接看出策略是否有超额收益。很多策略单看净值曲线在涨,实际和指数一比发现是普涨行情带来的,没有跑赢大盘,这种对比很有必要。

持仓分布用横向柱状图展示当前调仓日的个股权重,鼠标悬停可以看到具体百分比。回撤图用面积图,填充色设为浅红色,直观展示水下区间的时间和幅度。这三张图通过st.tabs组件放在三个页签里,用户点击切换,避免页面滚动太长。

净值曲线的plotly代码:

import plotly.graph_objects as go fig = go.Figure() fig.add_trace(go.Scatter( x=nav_df['date'], y=nav_df['strategy'], name='策略净值', line=dict(color='#d62728', width=2) )) fig.add_trace(go.Scatter( x=nav_df['date'], y=nav_df['benchmark'], name='沪深300', line=dict(color='#7f7f7f', width=1.5, dash='dash') )) fig.update_layout( title='回测净值曲线', xaxis_title='日期', yaxis_title='净值', legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1), height=500, margin=dict(l=10, r=10, t=60, b=10) ) st.plotly_chart(fig, use_container_width=True)

这个图表交互让用户可以直接缩放查看某段时间的持仓和收益细节,比静态图高效很多。比如看到净值曲线在2022年4月有明显回撤,鼠标框选那一段就能放大细看。用use_container_width=True让图表自动适配页面宽度,不同分辨率屏幕上显示都不会变形。

4.3 缓存机制与性能优化

Streamlit的脚本执行模型是每次交互都会从头到尾运行一遍全部代码。回测整个流程涉及大量数据请求和计算,如果每次拖动滑杆都重新拉数据,体验是灾难级的。所以缓存是必须做的优化。

数据处理用了@st.cache_data装饰器,它在缓存时会把函数输入参数序列化作为缓存键,返回的数据以pickle形式存在本地。注意缓存对象的可变性问题——st.cache_data返回的是深拷贝后的数据,所以页面里再怎么修改返回的DataFrame都不会污染缓存。这一点比早期的st.cache靠谱得多。

实际操作中我发现,把数据获取和指标计算分别缓存,比整个流程一起缓存更实用。市场行情数据每天更新一次,但财务指标数据每周才变一次,分开缓存可以避免每天都全量刷新。

@st.cache_data(ttl=3600) def load_market_data(start_date, end_date): # 拉取行情数据 pass @st.cache_data(ttl=86400) def load_financial_indicators(): # 拉取财务数据 pass

ttl参数控制缓存过期时间。行情数据设置1小时过期,盘中刷新页面也能拿到最新行情;财务数据设置一天过期,避免重复请求。

5. 常见问题与排查技巧实录

5.1 数据源接口不稳定与限流

akshare的接口是爬虫封装的,稳定性受目标网站影响,经常遇到单次请求超时的情况。我做了三层防护:

第一层是请求重试。用tenacity库的@retry装饰器,设置最大重试3次,每次间隔2秒:

from tenacity import retry, stop_after_attempt, wait_fixed @retry(stop=stop_after_attempt(3), wait_fixed=wait_fixed(2)) def fetch_stock_data(code): df = ak.stock_zh_a_hist(symbol=code, period="daily", adjust="qfq") return df

第二层是批量请求限速。拉全市场几千只股票数据时,如果并发太高容易被封IP。我在循环里加了时间间隔,每请求5只股票主动sleep一秒,实测好用。自己写代码时别贪快,批量拉数时保守一些,避免整个IP都被目标网站屏蔽。

第三层是本地缓存兜底。把已经拉取的数据以parquet格式存到本地目录,下一次运行时先用本地数据,接口失效时可以切换为读取本地缓存文件。数据新鲜度会有一定牺牲,但至少页面不会崩。

5.2 前视偏差与幸存者偏差问题

这个问题我觉得是回测系统最大的坑,没有之一。

前视偏差常见于财报数据的使用。财报公布日和报告期截止日之间通常有1到4个月的时间差。比如年报是12月31日截止,但实际披露往往在次年4月底。如果我在次年1月的调仓日就用年报数据选股,相当于预知了未来信息,回测收益会严重虚高。

解决方案是维护一个财报发布日期表,让每只股票在调仓日只能看到已披露的财报数据。akshare可以获取财报发布日期,我把发布日期和指标数据合并,用发布日期而非报告期截止日作为数据生效日期。

幸存者偏差更隐蔽。当前交易日能查到的股票池,天然排除了退市、停牌的公司。如果直接对当前股票池做历史回测,会高估策略收益,因为垃圾公司已经消失了。做历史回测时,需要用回测时点的股票列表,而不是现在的股票列表。

解决这个问题需要历史股票列表数据。akshare的stock_info_a_code_name只能拿到当前股票列表,历史列表需要从其他渠道获取。我目前的做法是采用“不处理”的方式,明确在页面提示回测结果可能存在幸存者偏差,仅供研究参考。回测结果作为相对比较的参考而非精确预期值,这在工具定位上是说得通的。

5.3 Streamlit运行常见错误与调试

Streamlit项目调试起来比较无脑。运行streamlit run app.py后,浏览器打开本地地址,代码改动后页面右上角会出现“Rerun”按钮,点击即可刷新。但也有几个经典问题。

第一个是缓存导致的更新不及时。改完数据获取函数,页面还是显示旧数据,多半是缓存没有清除。这时在侧边栏点“Clear Cache”按钮,或者直接重启服务,就能解决。

第二个是plotly图表不显示。这往往是plotly版本和streamlit版本不兼容导致。解决方案是把两个库升级到较新版本。有一次我遇到图内所有中文显示成方块,排查后是系统缺少中文字体,服务器上安装了fontconfig中文字体后解决。

第三个是端口被占用。Streamlit默认端口8501,如果被占用会自动尝试8502。我遇到过服务跑起来但页面一直转圈的情况,查下来是之前的进程没有杀干净,多个实例抢占同一个端口。用ps -ef | grep streamlit找到旧进程,kill掉再启动就好。

调试Stremlit还有一种思路是先用小数据模拟。我通常设置参数只选10只股票,回测区间缩短到一年,先确认逻辑跑通,再把参数放大。这样调试速度会快很多,也容易定位是哪一步出了问题。

6. 项目扩展方向与实际应用建议

这个系统打磨到现阶段,已经可以作为一个内部研究工具使用了。回测结果虽然不能代表未来收益,但至少能验证某些投资直觉是否站得住脚。如果后续想继续深入,还有几个扩展方向值得考虑。

数据源支持扩展方面,目前用akshare免费接口,数据质量和稳定性上限有限。可以考虑接入券商或专业数据服务商的接口,提高行情和财务数据的精度与时效性。如果只是个人小工具使用,可以在本地维护一个数据仓库,每日定时增量更新,减少接口依赖。

策略逻辑方面,当前是等权配置和简单筛选打分,后面可以引入均值方差优化或风险平价模型,根据股票间的协方差矩阵动态调整权重。这个改进会让持仓更加分散,理论上能改善组合的夏普比率。

性能方面,如果股票池扩大到全市场几千只股票,每日度净值计算的循环会变得很慢。可以考虑引入numba加速循环,或者用polar做向量化计算。数据量再大一些,可以加一层消息队列和并行计算框架来优化。

交互体验方面,可以增加策略保存和对比功能。用户可以把历史跑过的参数组合保存下来,生成多条净值曲线对比,方便横向评估不同策略的有效性。也可以加一个策略分页,把多个策略输进同一个数据库,便于统一管理和复盘。

如果要把平台共享给朋友用,Streamlit官方支持云部署,git push到GitHub仓库后关联Streamlit Community Cloud就能免费托管。本地跑起来和云端部署的数据源访问会有些差异,部署前需要确认服务器能正常访问akshare接口,网络问题会影响数据拉取。

我个人在这几个月里反复调整框架和参数,最大的体会是:回测的价值不在于找到一个收益率最高的参数组合,而在于理解策略在什么环境下会失效。这也是我坚持在页面里保留基准对比和最大回撤展示的原因。每次跑新策略,我都会先看看回撤情况,如果策略动不动就回撤30%以上,再高的年化收益也要打问号。这套系统让我对“用数据验证想法”有了更具体的体感,也希望它能帮你节省一些冤枉路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询