简介:基于布林指标与随机摆动指标的股票池量化策略分析,是一份面向具备一定量化基础读者的系统文档。内容覆盖从股票池构建到回测结果解读的完整研究链条:先以动态市盈率、每股收益、净利润环比增长率、净资产收益率、流动资产与流动负债差额、经营现金流量六大指标筛选出四只股票,并选取江山股份、天赐材料、盛新锂能、中矿资源等案例进行基本面分析;随后利用布林通道与随机摆动指标设计买卖择时规则,结合编程实现和回测参数设置,完成收益、成交与风险分析。资源包共含一个Word文档,压缩包大小约391KB,便于直接查看和按需修改。目前已有326人学习,文档结构清晰,从选股逻辑、指标说明到代码实现、回测结果逐步递进,对理解选股与择时指标的组合逻辑、搭建完整量化策略框架有实际参考价值,也可用作课程设计或量化入门项目的写作范本。
1. 为什么说“布林指标 + KD指标”组合适合股票池量化策略
很多量化爱好者在选指标时陷入一个误区:觉得某个指标单独使用就足以走遍天下。事实上,布林指标处理的是价格相对均线的波动幅度,KD指标处理的是当前价格在近期高低点之间的相对位置,两者一个管“波动边界”,一个管“动量强弱”,互补性很强。把它们组合进股票池量化策略,核心是用布林带先框出哪些股票正处于“均值回归”的潜在区域,再用KD指标去卡入场时间。这种思路对日线或周线级别的中低频交易尤其实用。适合有一定Python基础、想从单票交易转向批量筛选的技术从业者,下文会给出从数据读取到回测报表的完整可改代码。
2. 搭股票池:用Python构建可复现的量化分析底座
2.1 股票池量化策略需要先解决“数据面”问题
股票池量化策略的第一个瓶颈不是指标公式,而是数据怎么组织。常见做法是:用接口或CSV文件把全市场日线数据拉下来,统一成{code: DataFrame}字典结构,每个DataFrame的索引是交易日,列至少包含open/high/low/close/volume/amount。这里有两个容易踩的坑:第一个是除权除息没有复权,导致后复权与前复权混用,信号和回测全变样;第二个是交易日历不统一,不同股票因停牌导致的缺行,会直接破坏rolling窗口的连续性。
我一般在数据落地时做一次“标准化”处理:把价格列全部按前复权因子重算,并剔除上市不满60个交易日、当日停牌、近20日平均成交额低于3000万的个股。这样进池子的标的至少保证有一定流动性和可交易性,避免回测里出现“一支小盘股一天就能把曲线拉成直线”的失真场景。
另外,股票池不是每天重建一次,而是按固定调仓周期重建。我常用的做法是周频调仓:每周五收盘后重建股票池,下周一执行买入。这样既控制了换手率,又不会因为单日价格异动让池子剧烈抖动。
2.2 用最小代码实现基础股票池筛选
下面这段代码给定一个基准日date,从全市场日线字典中筛出当期股票池。它不依赖任何专门的量化框架,只用pandas,方便你在现有工程里直接嵌入。
import pandas as pd def build_stock_pool(daily_data, date, min_list_days=60, min_avg_amount=3e7, exclude_st=True): """按基准日生成股票池。 daily_data: dict,key 为股票代码,value 为 DataFrame, 索引是交易日,字段含 open/high/low/close/volume/amount/name date: 基准日字符串,格式 'YYYY-MM-DD' min_list_days: 上市最少天数,过滤次新股 min_avg_amount: 近20日平均成交额下限,单位元 exclude_st: 是否剔除 ST / *ST """ pool = [] for code, df in daily_data.items(): # 截取基准日之前的数据,避免用到未来数据 hist = df.loc[:date] if len(hist) < min_list_days: continue if exclude_st and 'name' in hist.columns: latest_name = str(hist['name'].iloc[-1]) if 'ST' in latest_name.upper(): continue if 'amount' in hist.columns: avg_amount = hist['amount'].tail(20).mean() else: avg_amount = 0 if avg_amount < min_avg_amount: continue pool.append({ 'code': code, 'last_close': hist['close'].iloc[-1], 'avg_amount': avg_amount }) return pd.DataFrame(pool)从逻辑上看,这个函数有几个关键判断点:len(hist) < min_list_days拦住上市时间过短的标的;latest_name中带ST的标的被剔除;avg_amount低于min_avg_amount的流动性过滤也被挡在外面。参数min_avg_amount我一般设 3000 万,如果你做的是小市值实验,可以降到 1000 万,但注意滑点风险会显著上升;反之做中大盘策略可以提高到 1 亿以上。
需要特别提醒的是,这个函数并没有对复权做处理。在实际应用中,你应该在把原始数据存入daily_data之前就完成前复权操作。不同数据源的前复权接口差异较大,但核心原则只有一个:回测与实盘必须使用同一套复权基准,否则信号的位置会整体偏移。
2.3 回测需要的最小框架:向量化与逐日循环的取舍
按日线做股票池策略,回测引擎不需要很复杂。常见做法是逐日循环:每天先判断股票是否还在池中,再对持仓标的打信号。这样做贴近实盘,方便加手续费、滑点和涨跌停限制;坏处是速度慢。单标的几千行数据无所谓,但全市场几千只股票滚动跑,速度就成问题。
所以工程上通常采用“先算好信号,再统一撮合”的思路:把指标向量化计算,buy/sell 两个布尔序列直接生成,回测时只做状态切换。下面这段python量化交易策略代码既是回测骨架,也是后续章节评估绩效的输入。
import pandas as pd import numpy as np def run_backtest(close: pd.Series, buy_signal: pd.Series, sell_signal: pd.Series, initial_cash=1_000_000, fee_rate=0.0003): """向量化信号前提下的逐日回测。 close: 收盘价序列,index 为交易日 buy_signal / sell_signal: 布尔序列,True 表示当天触发 fee_rate: 单边手续费率,默认万三 """ cash = initial_cash position = 0.0 equity = [] for i in range(len(close)): price = close.iloc[i] if buy_signal.iloc[i] and position == 0: # 全仓买入,手续费以持仓市值形式扣除 position = cash / price cash = 0.0 position *= (1 - fee_rate) elif sell_signal.iloc[i] and position > 0: cash = position * price * (1 - fee_rate) position = 0.0 equity.append(cash + position * price) return pd.Series(equity, index=close.index)这个回测函数假设当日信号当日按收盘价成交,是最大简化版。其中fee_rate是单边费率,一般 A 股默认万三到万五;如果考虑滑点,可以在函数外把成交价改成close * (1 + slippage),效果类似。更细的撮合逻辑,比如涨停买不进、跌停卖不出,需要在信号过滤阶段先处理,第五章会给出具体做法。
3. 布林指标与KD指标信号怎么算、怎么合并
3.1 布林指标计算公式与标准差口径
布林指标由中轨、上轨、下轨三条线组成。中轨是N日均线,上下轨是中轨加减K倍标准差。这里有一个统计口径差异:pandas 的std()默认ddof=1,而布林指标的标准差一般取总体标准差ddof=0。两者在小窗口下差异尤其明显,20日窗口大约有百分之几的偏差,直接影响上下轨宽度。下面给出一段标准实现,直接指定ddof=0,避免不同版本回测口径不一致:
def bollinger_bands(close: pd.Series, window: int = 20, num_std: float = 2.0): """计算布林指标上中下轨。 close: 收盘价序列 window: 均线窗口,默认20;周线策略可改用 20*5=100 num_std: 标准差倍数,默认2 返回 (upper, middle, lower) """ middle = close.rolling(window=window).mean() std = close.rolling(window=window).std(ddof=0) upper = middle + num_std * std lower = middle - num_std * std return upper, middle, lower为什么num_std默认取 2?因为在正态分布假设下,价格落在均值±2倍标准差内的概率约为95%。实际金融数据不服从正态分布,布林带给出的“统计带”更多是情绪参考:当价格贴着下轨运行,说明短期情绪偏弱,均值回归概率在提升,但不是说一定会反弹。这也就是为什么只用布林带一个指标,容易在单边下跌行情里反复抄底亏损——价格可以在下轨之下运行很久。加入KD指标作为二次确认,就是用来过滤掉这种“情绪弱但动能仍在向下”的场景。
3.2 KD指标计算公式与递归初始化细节
KD指标的核心是 RSV,随后对 RSV 做指数平滑得到 K 和 D。标准公式如下:
RSV = (收盘价 - 9日最低价) / (9日最高价 - 9日最低价) * 100 K = 2/3 * K(前一日) + 1/3 * RSV D = 2/3 * D(前一日) + 1/3 * K初始 K 和 D 一般取第一个 RSV 或直接取 50。用 pandas 的ewm可以直接实现递归平滑。注意alpha要设为1/3,并且adjust=False,这样才能匹配标准KD的更新式。
def kd_indicator(df: pd.DataFrame, n: int = 9, alpha: float = 1/3): """计算KD指标的K值和D值。 df: 必须含 high, low, close 三列 n: RSV 的回看窗口,默认9 alpha: K 与 D 的平滑系数,标准值为1/3 """ low_n = df['low'].rolling(window=n).min() high_n = df['high'].rolling(window=n).max() rsv = (df['close'] - low_n) / (high_n - low_n) * 100 rsv = rsv.fillna(50.0) k = rsv.ewm(alpha=alpha, adjust=False).mean() d = k.ewm(alpha=alpha, adjust=False).mean() return k, d这里有个容易被忽略的细节:ewm(adjust=False)的递归公式是y_t = (1 - alpha) * y_{t-1} + alpha * x_t,正好对应 K 的更新式。如果误用默认的adjust=True,结果会变成从起始点向当前值的全量加权平均,前面几百行的K值会与标准KD差异巨大,信号位置也会整体偏移。
另外,rsv.fillna(50.0)这一句是给前n-1行一个中性初值,避免最前面的 K 和 D 出现 NaN。如果你准备逐日滚动计算而非一次性全量计算,这个初值决定权在你自己,但标准做法是取 50,不会对后续稳态产生实质影响。
3.3 组合信号设计:布林定位置,KD定时点
单用布林带,你得到的是一个价格通道;单用 KD,你得到的是一个摆动区间。把它们组合成“布林+KD”的入场信号,我常用一个非常朴素的逻辑:布林带下轨与KD低位金叉同时发生才买入。“低位”指 K 值低于 20 或 30,“金叉”指 K 从下向上穿越 D。
def generate_signals(df, bb_window=20, bb_std=2.0, kd_n=9, kd_low=20): """生成布林+KD组合买入/卖出信号。 df: 含 open/high/low/close 的日线 DataFrame 返回 df 副本,新增 buy_signal / sell_signal 两列 """ df = df.copy() upper, middle, lower = bollinger_bands( df['close'], bb_window, bb_std) k, d = kd_indicator( df[['high', 'low', 'close']], n=kd_n) # 买入:前一日收盘仍在下轨之上, # 当日收盘跌破下轨,且 K 在低位上穿 D buy_signal = ( (df['close'].shift(1) >= lower.shift(1)) & (df['close'] < lower) & (k > d) & (k.shift(1) <= d.shift(1)) & (k < kd_low) ) # 卖出:价格向上穿越中轨,或 K 值大于 80 后死叉 sell_signal = ( (df['close'] > middle) | ((k < d) & (k.shift(1) >= d.shift(1)) & (k > 80)) ) df['buy_signal'] = buy_signal df['sell_signal'] = sell_signal return df这里是整个策略的核心。买入信号加了三层过滤:跌破下轨、KD金叉、K值在低位。为什么不直接用“当日收盘跌破下轨就买”?因为跌破下轨既可能是均值回归机会,也可能是趋势加速的第一天,单独看布林带无法区分。KD低位金叉作为附加条件,是在用动量指标确认下跌动能正在衰减。
卖出信号没有做太严格的限制。原因在于这个策略的盈利来源是中轨回归,价格一旦回到布林带中轨以上,意味着“从偏离到回归”的过程大概率已经走完;K值80以上的高位死叉作为止盈保护,防止在极端强势行情中过早下车。如果你在实盘里觉得卖出太频繁,可以再加一条持仓时间不小于5日的最低持有规则,但这属于交易成本与信号频率的权衡,不是策略逻辑的必要条件。
4. 回测结果评估与参数调优
4.1 核心绩效指标:用夏普而不是总收益评判
回测跑完,不能只看总收益,还要看年化、最大回撤、夏普比率、胜率和盈亏比。用第三章的run_backtest函数拿到权益曲线后,我通常立刻算下面几个指标:
def evaluate_equity(equity: pd.Series, periods_per_year=252): """从权益曲线计算核心绩效指标。 equity: 逐日权益序列 periods_per_year: 年交易日数,日线取252,周线取52 """ ret = equity.pct_change().dropna() total_return = equity.iloc[-1] / equity.iloc[0] - 1 years = len(equity) / periods_per_year annual_return = (1 + total_return) ** (1 / years) - 1 peak = equity.cummax() drawdown = (equity - peak) / peak max_drawdown = drawdown.min() # 夏普比率,无风险利率按0计 sharpe = ret.mean() / ret.std() * (periods_per_year ** 0.5) return { 'total_return': total_return, 'annual_return': annual_return, 'max_drawdown': max_drawdown, 'sharpe': sharpe, 'volatility': ret.std() * (periods_per_year ** 0.5) }最大回撤是这个策略最需要盯的指标。布林+KD这种均值回归型策略,往往胜率不错,但偶尔会遇到单边下跌的品种,下轨不断下移、买进去就被套。如果最大回撤超过20%,说明股票池里混进了不该有的趋势性下跌个股,此时应优先调整入池条件,而不是盲目修改指标参数。
4.2 三个核心参数怎么选:布林窗口、带宽倍数、KD周期
布林和KD合在一起,最关键的可调参数有三个:布林窗口bb_window、带宽倍数bb_std、KD周期kd_n。下表是常见取值范围和它们对结果的影响方向:
| 参数 | 常见取值 | 参数变大时的表现 | 参数偏小时的典型问题 |
|---|---|---|---|
| bb_window | 15~30 | 通道更宽,信号更少 | 信号频繁,手续费侵蚀收益 |
| bb_std | 1.5~3.0 | 触发更难,假信号减少 | 假突破增多,胜率下降 |
| kd_n | 5~14 | KD更平滑,金叉更晚 | 过早金叉,下跌中继被套 |
参数的选取没有全局最优。比较稳妥的做法是网格遍历后看参数平面的稳定性:如果3×3×3的参数空间里只有少数几组参数赚钱,绝大多数亏钱,说明策略本身不稳健,靠的是参数拟合;如果大片区域都能盈利且回撤差距不大,说明策略逻辑成立,参数选择只是微调。
4.3 用滚动窗口做参数调优,防止前视偏差
“前视偏差”是参数调优中最容易犯的错。正确做法是:用历史某段时间内的数据选出参数,再用没参与选参的后一段数据做样本外验证。下面给出两段式网格搜索的骨架:
from itertools import product def grid_search(daily_df, param_grid, in_sample_dates, out_sample_dates): """两段式网格搜索:样本内选参,样本外验证。 daily_df: 单只股票或股票池等权组合的行情 DataFrame param_grid: {'bb_window': [15, 20, 25], 'bb_std': [1.5, 2, 2.5], 'kd_n': [9, 14]} 返回样本内外核心指标组成的 DataFrame """ results = [] for bb_window, bb_std, kd_n in product( param_grid['bb_window'], param_grid['bb_std'], param_grid['kd_n']): df = generate_signals(daily_df, bb_window, bb_std, kd_n) df_is = df.loc[in_sample_dates] df_oos = df.loc[out_sample_dates] eq_is = run_backtest(df_is['close'], df_is['buy_signal'], df_is['sell_signal']) eq_oos = run_backtest(df_oos['close'], df_oos['buy_signal'], df_oos['sell_signal']) results.append({ 'bb_window': bb_window, 'bb_std': bb_std, 'kd_n': kd_n, 'is_sharpe': evaluate_equity(eq_is)['sharpe'], 'oos_sharpe': evaluate_equity(eq_oos)['sharpe'], }) result_df = pd.DataFrame(results) return result_df.sort_values('oos_sharpe', ascending=False)一个实际工程要点:股票池场景下不一定对每只股票单独调参,而是对“全池子等权组合”调同一组参数。原因是个股噪音大,单独调参容易过拟合;组合级别的参数普适性更高。我在项目管理几十只股票时只跑组合级网格,个股级调参的人力成本完全不划算。
5. 把回测报表当体检报告:三个必须看的隐藏项
很多人拿到一份漂亮的回测权益曲线就开始兴奋,但回测报表里最关键的三个“隐藏项”往往不在首页。
第一个是幸存者偏差。如果你用的股票池是今天存在的股票、而不是历史当时存在的股票,那么退市股和暴跌股从一开始就不在回测里,年化收益虚高10%以上都很常见。解决办法是使用带退市标记的全量历史数据,或者在入池流程中保留“之后被退市”的标的直到退市日。
第二个是手续费与滑点的真实占比。布林+KD组合策略的交易频率不低,按20日布林带、每周调仓来算,年单边换手可能到20倍以上。回测里如果只用万三佣金且不算滑点,夏普比率会比较好看;一旦把滑点按0.1%计入,很多参数组合会从盈利变亏损。验证手段很简单:把fee_rate从0.0003改成0.001,看年化收益缩水多少,缩水幅度超过40%就要考虑降低调仓频率。
第三个是出场信号是否真的可执行。这个策略的买入条件是“跌破下轨 + KD低位金叉”,成交价是按收盘价模拟的。但在跌停板上收盘买入往往无法成交;涨停板上卖出也同理。更保守的做法是在信号生成后加一条规则:当日不是一字涨停或跌停才允许成交。代码里加一行过滤即可:
def apply_limit_filter(df): """剔除一字涨跌停日的伪信号。""" limit_up = df['close'] >= df['close'].shift(1) * 1.095 limit_down = df['close'] <= df['close'].shift(1) * 0.905 df.loc[limit_up | limit_down, ['buy_signal', 'sell_signal']] = False return df把generate_signals的输出接上这个过滤,再进回测,曲线会朴素很多,但每一笔收益都是实盘里拿得到的。拿这个口径去对比优化前后的夏普比率,你才能真正判断“布林+KD”这一套框架在股票池量化策略中值不值得上线。
本文还有配套的精品资源,点击获取