使用Pandas+InterpretML分析高换手后的上涨趋势延续成功率


一、引言

最近有小伙伴留言,在量化策略研究中有一个值得关注的现象:部分特征指标在整体统计层面表现平平,但通过数据分段处理后可呈现显著提升的有效性区间。
今天我们就以“高换手率”为场景来看一下:

  1. 趋势延续性:在上涨的趋势中,如果当日出现高换手率,第二天依然能保持较大涨幅的股票数量有多少?
  2. 复合因子有效性:当日除了高换手率之外,还有哪些具有协同增强效应的技术特征?从近期历史数据来看,这些特征的阈值是多少?
  3. 工具的使用:支持特征分段分析的工具 —— Pandas.cut 和 InterpretML.

【概念厘清】 股票换手率,指特定周期内成交量占流通股本的比例,计算公式为:

换手率=成交量流通股本×100%换手率 = \frac{成交量}{流通股本} \times 100\%

高换手率通常被视为市场活跃度的指标,但其背后的原因和影响因素复杂多样,可能由多种因素引起,如市场热点、利好消息、主力资金操作等。

【方法论警示】 必须明确指出:技术分析本质是通过统计规律揭示特征相关性,而非建立因果推论。
当我们做量化分析时,应始终谨记以下原则:

  1. 数据驱动:基于历史分布规律构建概率模型。
  2. 风险提示:任何统计显著区间均存在失效概率。
  3. 动态校验:建立季度参数校准机制应对市场风格的切换。

二、本文数据范围定义

1. 高换手率

换手率的定义是:成交量(Volume) / 流通股本(float_share)
那么,如何定义这个比例的高低?

在本篇博客上下文中,我暂时将高换手率的范围圈定为:

换手率(turnover_rate)接近该股票历史最大换手率(history_max_turnover_rate),
且自由流通股换手率(turnover_rate_f) 大于20%。

字段解释:

  • 股本,分为总股本(total_share)、流通股本(float_share) 和 自由流通股本(free_share)。
    在最新更新的数据集(dataset)中,这 3 个字段的数据都会提供。
    所谓”自由流通股本“,是指扣除了十大流通股东、持股5%以上等(减持时必须发公告)股本之后,剩下的部分”流通股本“。
  • 自由流通股换手率(turnover_rate_f),即 volume / free_share,在数据集中也会直接提供 turnover_rate_f 值。

2. 分类标签

当圈定了历史数据中出现”高换手率“的股票之后,我们对这些股票进行打标分类:

出现高换手的第二天 —— 收盘价是否涨幅 5% 以上?

是,则标记为正类(值为1); 否,是负类(值为0)。
即常说的二分类。

需要查看不同定义范围的小伙伴,可以克隆 huggingFace Demo 脚本,在本地修改条件运行。

下面将介绍如何使用 Pandas 和 InterpretML 等工具进行数据统计和特征分析。
想先看结论的小伙伴,也可以直接跳到最后一章。

三、代码操作

1. HuggingFace 数据集

这次依然使用 HuggingFace dataset ellendan/a-share-21,具体为all-prices-with-values-250303.csv文件。
250303 这次更新,除了以前的日线价格字段外,增添了以下关键字段:

  • total_share,总股本。
  • float_share, 流通股本。
  • free_share,自由流通股本。
  • total_mv,总市值,单位万元。
  • circ_mv,流通市值,单位万元。
  • turnover_rate_f,自由流通股换手率,单位%。
  • exchange,交易所。SSE 是上交所;SZSE 是深交所。
  • market, 市场类别 。主板 / 创业板 / 科创板 / CDR
import pandas as pd
from stock_pandas import StockDataFrame

pd.options.display.max_columns = None
# 需要从 HuggingFace dataset 上手动下载的 csv 文件放在本地。本代码库的 dataset 目录已经加入了.gitignore,不会将数据集提交到代码库。
data_path = '../../dataset/all-prices-with-values-250303.csv'
prices_df = pd.read_csv(data_path, index_col=['code', 'date'])
prices_df.tail()

2. 数据处理

在进行分析之前,我们需要对特征进行处理。
这里跟以前一样,主要使用 Pandas 和 stock-pandas 工具来完成。
详情见源代码脚本,脚本地址
以下是数据处理的代码片段截图:
数据处理
说明:

  1. 数据集中包括了上交所和深交所所有的股票。
    因此,在处理特征时,一定要记得按股票代码分组进行处理。不然,像 expanding()、rolling()、diff() 等跨记录行的多行运算,会算错。
  2. (source_data_frame['is_st'] == 0) & (source_data_frame['market'] == '主板')
    • 数据集已经添加了market字段,可以直接排除掉 ST 股票(涨停限价 5%)和非主板(涨停限价 20%)的股票。
  3. 准备好用来过滤股票范围的条件特征。
    虽然这些特征也可以用来做分段分析,但需要注意 —— 在它被作为条件之后,分段范围会缺失一部分区间。
    • bullish,当日烛线图是否是红柱。
    • pre_trend_up,最近20日的趋势:是否价格上涨10%。
    • high_limit_count, 最近10日的涨停板数量。
    • turnover_rate, 换手率。
    • turnover_rate_f,自由流通股换手率。
    • history_max_turnover_rate, 当天之前(包括当天)出现过的历史最大换手率。
    • history_turnover_rate_f,历史最大换手率那天的自由流通股换手率。
    • not_break_ceiling,开盘价非涨停价,用来去掉破板股的极端数据。
  4. 分类标签:((stock_df['change:close'] >= 0.05) * 1).shift(-1),第二天收盘时是否达到涨幅 5% 以上。
  5. 想要进行分段查探的特征。
    • circ_mv,流通市值,源数据集单位是万元,这里换算成亿元
    • turnover, 成交额,源数据集单位是,这里换算成亿元
    • norm_quote_rate,归一化后的涨跌幅。
      由于股票价格的变动对应到涨跌幅时,涉及到小数四舍五入的问题,因此涨跌停的幅度并不是标准的 ± 10%。为了避免数值超过 10% 的这样的小偏差,这里将价格对照到各个股票涨跌停价格区间进行数值归一化,让涨跌幅控制在 ± 1 范围内。
    • dde_l,大单净成交额,单位元(数据来源同花顺)。包括特大单+大单:单笔成交量>60000股,或者单笔成交金额>30万。
    • net_flow_rate,大单净流入率,单位 %(数据来源同花顺),dde_l / turnover

3. 按条件进行数据整体统计

利用前面准备好的 condition 部分的特征字段,我们对股票数据范围进行筛选。
除了高换手率条件之外,还需要添加”近20日价格趋势上涨“、”当日收红“、”非破板“这些限定条件。
最后统计圈定的数据中,正类占总数量的比率。
代码如下:

# 换手率接近历史最高换手率,且换手量占自由流通盘的比例 >= 20%
_high_turnover_rate_condition = (_post_df['turnover_rate'] >=(_post_df['history_max_turnover_rate'] * 0.8)) & (_post_df['turnover_rate_f'] >= 20)

_filtered_df = _post_df[(_post_df['pre_trend_up'] > 0)
                & (_post_df['not_break_ceiling'] > 0) # 非破板
                & (_post_df['bullish'] > 0) # 看涨
                & _high_turnover_rate_condition]

print(f"满足条件的总数据量:{_filtered_df.shape[0]}")
print(f"其中达成目标的数量:{_filtered_df[_filtered_df['target'] > 0].shape[0]}")

ratio = round(_filtered_df[_filtered_df['target'] > 0].shape[0] / _filtered_df.shape[0], 2)
print(f"平均成功率:{ratio}, 即 {ratio*100}%")

运行后显示:

满足条件的总数据量:5130 
其中达成目标的数量:1173 
平均成功率:0.23, 即 23.0%

也就是,按选股条件筛选出了 5130 次股票数量,其中达成第二日涨幅 >= 5% 的股票一共是 1173 次,** 整体统计的成功率是 23 %**。

4. 探查特征内各分段的数据分布

4.1 使用 pandas cut 进行分段统计

首先,我们只使用 pandas,不使用其他工具对特征进行分段查看,关键函数 pandas.cut (有需要的也可以试试 pandas.qcut 函数)。

import pandas as pd
feature_names = ['turnover_rate', 'turnover_rate_f', 'circ_mv', 'turnover', 'norm_quote_rate', 'dde_l', 'net_flow_rate', 'high_limit_count']

_bin_filtered_df = _filtered_df.copy()
results = []
for feature in feature_names:
    _bin_filtered_df[f'{feature}_bin'] = pd.cut(_bin_filtered_df[feature], bins=10, precision=0)
    # 统计每个分段的样本数和正类占比
    grouped = _bin_filtered_df.groupby(f'{feature}_bin', observed=True)['target'].agg(
    total='count',
    positive_rate=lambda x: round(x.mean(), 2),
).reset_index()
    grouped = grouped.dropna()
    display(grouped)

说明:

  • cut()函数,按一维数组的 min ~ max 值区间将数组分隔成 bins参数(这里是10)个区间格子。
  • 我们将需要分析的所有股票数据(5130条),放入各个特征分段区间进行数量统计count(总数)、positive_rate(正类的占比,因为正类的值为1,负类为0,因此求平均值mean()正好就是占比值)。

运行结果,如下图:
pandas cut()结果
说明:

  • 从以上8个特征的数值等区间分段统计来看,的确有一些特征区间的 positive_rate > 0.23(整体数据的正类占比)。
    可以关注一下这些分段区间,但同时也需要注意该区间的total值,过小的基数造成的 positive_rate 的提升可能具有偶然性。排除掉一些过小数据权重的区间外:
    • turnover_rate_f > 41%区间,正类占比高于平均值。
    • norm_quote_rate > 0.8区间,正类占比 30%。
    • high_limit_count > 2区间,正类占比至少高于 27%。
  • 一些特征区间数据过于集中,比如circ_mv_bin等于(3, 180)这个区间占据了 92% 以上的数据,只是等区间的 cut() 很难观察到该特征的细节特性。

4.2 使用 matplotlib 进行交叉分段分析

Pandas cut 只能查看到单一特征分段的特征分布,如果想要看交叉特征,如何实现呢?
这里,我们先用 matplotlib 热力图对一组交叉特性进行分段可视化 —— 换手率(横轴) + 流通市值(纵轴)。

import numpy as np
import matplotlib.pyplot as plt

# 设置字体,让 matplotlib 图片可以显示中文
plt.rcParams['font.sans-serif'] = ['Songti SC']
plt.rcParams['axes.unicode_minus'] = False

def plot_heatmap_with_ratio(_df, vertical_step=20):
…………(见源文件脚本)…………

_circ_mv_segment_1 = _filtered_df[_filtered_df['circ_mv'] < 300]
_circ_mv_segment_2 = _filtered_df[_filtered_df['circ_mv'] >= 300]

plot_heatmap_with_ratio(_circ_mv_segment_1)
plot_heatmap_with_ratio(_circ_mv_segment_2, vertical_step=300)

说明:

  • 通过前面 cut 分段可知:circ_mv(流通市值)数值范围跨度大,从3亿~1800亿,同时数据主要集中在第一个区间。因此,我们将 circ_mv 分成两个大的区间各绘制一套热力图 —— 小于300亿元和大于300亿元。

绘图结果:
流通市值<300亿
流通市值>=300亿
说明:

  • 每套图有三张子图:(左)正类的数据分布,(中)负类的数据分布,(右)正类占比。
    与左子图、中子图不同的点:在(右)子图中,没有数据的栅格显示”灰色“,只有负类的栅格显示”蓝色“,其他有正类分布的栅格显示”红色“,数据越大,颜色越深。
  • 虽然从左子图和中子图中发现,数据集中在:20亿 < circ_mv < 100亿 和 10% < turnover_rate < 40% 之间。但通过右子图发现,在这个区间的正类占比并没有太显著的提升。

4.3 使用 InterpretML 对特征进行解释性分析

如果每个单一特征和交叉特征,都使用上面两种方式手动可视化,效率会非常的低。
因此,这里借助 InterpretML 对各特征分段进行解释。

InterpretML 是微软开源的一款专注于机器学习模型可解释性的 Python 工具库,旨在帮助用户理解和解释模型的决策逻辑。它提供两类核心功能:

  1. 模型无关解释:支持对各类黑箱模型(如神经网络、集成模型)进行全局特征重要性分析(如 SHAP 方法)和局部预测解释(如 LIME 技术),揭示模型整体规律及单个样本的决策依据;
  2. 明箱模型构建:内置可解释的模型(如可解释提升机 EBM),在保证高精度的同时直接输出清晰的规则,例如特征分箱对结果的影响。其可视化工具直观展示分析结果,适用于医疗、金融等高信任度需求场景,助力模型调试、合规审查及决策透明度提升。

下面使用的就是明箱模型 EBM,对数据特征进行分段规则解释。
代码调用非常简单:

from interpret.glassbox import ExplainableBoostingClassifier
from sklearn.metrics import roc_auc_score
from interpret import show

X, y = _filtered_df[feature_names].to_numpy(), _filtered_df['target'].to_numpy()

ebm = ExplainableBoostingClassifier(feature_names)
ebm.fit(X, y)

# 使用 ROC-AUC 简单进行评估
auc = roc_auc_score(y, ebm.predict_proba(X)[:, 1])
print("AUC: {:.3f}".format(auc))

ebm_global = ebm.explain_global()
show(ebm_global)

说明:

  • 使用 ROC-AUC 进行简单评估。如果该值 <= 0.5,基本等于随机猜测,模型没学到任何东西。ROC-AUC 越大越好,最大值为 1。
  • 调用 interpret.show()函数会在 Jupyter Notebook 上自动绘制出可交互的可视化图表。

总体结果:
各特征针对整体数据的加权平均分排行
说明:

  • 从图上可以看到,在全局样本数据的加权平均的情况下,贡献度最高的(对正确判定二分类的贡献,也叫特征重要性),是单一特征的norm_quote_ratehigh_limit_count
  • 图上显示两特征交叉,全局评分最高的是 circ_mvnorm_quote_rate 的交叉,具体的局部分箱贡献,需要选进去看 interaction 图。
  • 默认情况下,ExplainableBoostingClassifier 模型最多只进行两个特征的交叉,如果期望三个特征以上的交叉,需要代码显式指定。
  • 默认显示重要性前 90% 排名的特征,如果想要查看的特征没有显示在 Summary 直方图内,则说明该特征的重要性评分比最后一名还要低。

分段(分箱)结果:
norm_quote_rate 分段的贡献度
说明:

  • 从图上可以看到,数据大多集中在 norm_quote_rate > 0.9这个区间,但 0.9 ~ 1.0 这个区间的偏差特别大。
  • 只有当 norm_quote_rate == 1.0 时,对于正类的预测才有较大的贡献度。
  • 0.9 < norm_quote_rate < 0.98 这个区间,score 接近于0,对于分类预测几乎没有贡献。
  • 0.98 < norm_quote_rate < 1.0 这个区间,score 小于0,对于负类的预测有一定作用。
    个人看法,猜想背后的逻辑的 —— 高换手的情况下,涨停会大概率提升第二天继续大幅上涨的信心,而封板失败,会降低第二天继续大幅上涨的几率。

high_limit_count 分段的贡献度
说明:

  • 在 high_limit_count >=3 之后的区间,随着涨停板数量的增加,逐渐增大正类判定的概率。
  • 在 high_limit_count < 3 的区间,基本只会加大负类的判定概率。
  • 因此,当一只股票近10日涨停板的数量大于3的正类概率大于另一只股票近十日涨停板的数量小于3为正类的概率。

turnover_rate & high_limit_count 交叉特征
说明:

  • 双特征交叉,可视化图表就是热力图。Z 轴值为 Score,通过高亮度的热力块可观察到贡献度高的区域。比如,上图中左上角蓝色框的区域,对正类的判定贡献度很高。
  • 特征交叉页面不会显示各特征的样本数据权重。查看样本数量分布,还是需要去单特征局部图查看。

这里只简单列举了3个局部展开图,想要看到所有特征分段贡献表现的,可 clone 脚本到本地自行运行。

5. 添加涨停作为选股条件进行验证

_checked_filtered_df = _post_df[(_post_df['pre_trend_up'] > 0)
    & (_post_df['not_break_ceiling'] > 0)
    & (_post_df['bullish'] > 0)
    & (_post_df['norm_quote_rate'] >= 0.9) # 添加条件
    & (_post_df['high_limit_count'] >= 3) # 添加条件
    & _high_turnover_rate_condition]

print(f"满足条件的总数据量:{_checked_filtered_df.shape[0]}")
print(f"其中达成目标的数量:{_checked_filtered_df[_checked_filtered_df['target'] > 0].shape[0]}")

ratio = round(_checked_filtered_df[_checked_filtered_df['target'] > 0].shape[0] / _checked_filtered_df.shape[0], 2)
print(f"平均成功率:{ratio}, 即 {format(ratio * 100, '.2f')}%")

打印结果:

满足条件的总数据量:1283 
其中达成目标的数量:456 
平均成功率:0.36, 即 36.00%

注意:
虽然理论上,我们可以将条件提高到high_limit_count >= 9,这时正确率会从 23% 提升到了 56%,但”10天内涨停板9次以上”本身就是一条非常极端概率的情况,近 400 个交易日也仅有 27 条股票数据满足条件。因此,缺少数据去验证 —— 这个效果最好的特征阈值是偶然的,还是必然的?
就像我们如果使用 high_limit_count > 910 < turnover_rate < 15 作为添加条件的话(上面 turnover_rate & high_limit_count 图中蓝色框中高亮的部分),成功率可以达到 100%,但数据样本却只有2条。
所以,一些小样本区间的数据推论,能否独立地拿来预测未来数据,只能各人见仁见智了。

四、结论

  • 业务场景方面:
    1. 从近期400个交易日数据来看,上升趋势中出现高换手率的股票有 5130 次,其中第二日继续增长 5% 以上的有 1173 次,整体统计的占比是 23%
    2. 高换手率当日,如果增加当日收盘涨停近10日有涨停板3次以上的这两个特征区间条件,可以提升第二日涨幅 5% 以上的判定概率。占比最少提升到 36%。
  • 特征分段技术: 简单的分段可以使用 Pandas 和 Matplotlib,想要全局分段解释则更建议使用 InterpretML 这样的工具。

注意:以上业务结论都基于文章开头的数据范围定义,想要探索不同定义条件的小伙伴,请动手克隆代码库,本地修改运行后查看。


文章作者: Ellen Dan
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-SA 4.0 许可协议。转载请注明来源 Ellen Dan !
评论
  目录