第 4 章 · 02 Bollinger Bands:W 底形态识别 本节摘要:本节精读 Bollinger Bands 形态识别——一种纯算术的双底(W 底)抓取策略。布林带本身很简单,就是 20 日 SMA ± 2 倍移动标准差;但形态识别是另一回事:可视化对人类很容易,对机器却很难。本节的看点不在公式,而在 那段 4 层嵌套循环——它用 75 日窗口回溯找 W 形的 5 个节点 l-k-j-m-i(左上 / 左下 / 中 / 右下 / 右上),用 alpha=0.0001 的容差判定"贴近",用 beta=0.0001 的带宽阈值判定"收缩清仓"。作者甩出一句宣言:"为什么用机器学习,当算术更快更简单?" 这是本节的哲学底色。
本节摘要:本节精读 Bollinger Bands 形态识别——一种纯算术的双底(W 底)抓取策略。布林带本身很简单,就是 20 日 SMA ± 2 倍移动标准差;但形态识别是另一回事:可视化对人类很容易,对机器却很难。本节的看点不在公式,而在
signal_generation那段 4 层嵌套循环——它用 75 日窗口回溯找 W 形的 5 个节点 l-k-j-m-i(左上 / 左下 / 中 / 右下 / 右上),用 alpha=0.0001 的容差判定"贴近",用 beta=0.0001 的带宽阈值判定"收缩清仓"。作者甩出一句宣言:**"为什么用机器学习,当算术更快更简单?"** 这是本节的哲学底色。
涉及源码:原项目
Bollinger Bands Pattern Recognition backtest.py(约 237 行),数据gbpusd.csv(histdata.com 1 分钟 bid/ask 平均值)。
⚠️ 注意:本节策略的核心难点是 5 个节点的查找顺序——迭代点是"右上节点 i",却要从 i 往回找 j、k、l、m。务必先在纸上画一个 W 形并标好 5 个节点的位置,再看代码,否则 4 层 for 循环会把人绕晕。
阅读完本节,你应当能够:
布林带是一个非常朴素的指标——就是移动平均加上移动标准差。bollinger_bands 函数(L39-47)三行就能讲完:
data['std'] = data['price'].rolling(window=20, min_periods=20).std() data['mid band'] = data['price'].rolling(window=20, min_periods=20).mean() data['upper band'] = data['mid band'] + 2 * data['std'] data['lower band'] = data['mid band'] - 2 * data['std']
中带是 20 日简单移动平均(SMA),上下带各加减 2 倍移动标准差。2σ 在正态分布下覆盖约 95% 的样本,所以价格"正常"情况下应该落在带内,触带或破带就是异常信号。窗口 20 是惯例(约一个月交易日),min_periods=20 保证前 19 天是 NaN 而非瞎算。
作者在注释里点出布林带的三种用法:
作者在文件开头甩出一句近乎挑衅的宣言:
"when we talk about pattern recognition these days, people always respond with machine learning. why machine learning when u can use arithmetic approach which is much faster and simpler?"
这是一个重要的方法论立场:形态识别不一定要上 CNN/LSTM,把"贴近下带""带宽收缩"这些人眼识别 W 底的判据翻译成 alpha 容差判断,就足以让机器跑出可解释、可调参、计算极快的策略。本节的 5 节点查找就是这套"算术形态学"的范例。
W 形从左到右、从上到下有 5 个节点,作者命名为 l-k-j-m-i(注意不是字母顺序):
l i ← 第一顶(左上) 右上(突破上带) \ / k j m ← 第一底(左下) 中 第二底(右下) \ / \ / \_/ \___/
investopedia 建议:双底形态应用 3 个月视野,即 75 个交易日作为回溯窗口(period=75)。
Bollinger Bands Pattern Recognition backtest.py:67-169 的 signal_generation 是全策略的灵魂。两个超参:
alpha = 0.0001 # 价格与布林带的贴近容差 beta = 0.0001 # 带宽收缩的清仓阈值
外层循环从 i = period(75)开始,每个 i 是一个潜在的"右上节点"。查找顺序是反直觉的——从 i 往回找,先找 j 再找 k 再找 l 最后找 m:
第 0 层 触发条件(L110-111):i 点价格必须突破上带,且当前无持仓:
if (df['price'][i] > df['upper band'][i]) and (df['cumsum'][i] == 0):
第 1 层 找中节点 j(L113-119):从 i 往回扫,找一个价格贴近中带、且中带贴近上带(在 i 处)的点:
for j in range(i, i-period, -1): if (np.abs(df['mid band'][j] - df['price'][j]) < alpha) and \ (np.abs(df['mid band'][j] - df['upper band'][i]) < alpha): moveon = True; break
两个条件:价格在中带附近(中节点特征),且当时的中带水平与 i 处的上带差不多(说明 W 的"高度"合理)。
第 2 层 找第一底 k(L121-129):从 j 继续往回,找一个贴近下带的点,记下它的价格作为 threshold:
for k in range(j, i-period, -1): if np.abs(df['lower band'][k] - df['price'][k]) < alpha: threshold = df['price'][k] moveon = True; break
第 3 层 找第一顶 l(仅供画图,L131-138):从 k 往回,找一个价格高于中带的点(画 W 的左上角):
for l in range(k, i-period, -1): if df['mid band'][l] < df['price'][l]: moveon = True; break
第 4 层 找第二底 m(L140-152):这次从 i 往 j 方向(向左小范围)扫,找一个贴近下带、低于下带上方、且**价格低于 threshold(k 的价格)**的点:
for m in range(i, j, -1): if (df['price'][m] - df['lower band'][m] < alpha) and \ (df['price'][m] > df['lower band'][m]) and \ (df['price'][m] < threshold): df.at[i, 'signals'] = 1 df.at[i, 'coordinates'] = '%s,%s,%s,%s,%s' % (l, k, j, m, i) df['cumsum'] = df['signals'].cumsum() moveon = True; break
5 个节点全部凑齐才算一个有效 W 底,在 i 处发出 signals=1(做多),并把 5 个节点的行号存进 coordinates 字符串列(画图用)。
最外层循环还有一个退出分支(L163-167):
if (df['cumsum'][i] != 0) and (df['std'][i] < beta) and (moveon == False): df.at[i, 'signals'] = -1 df['cumsum'] = df['signals'].cumsum()
带宽(移动标准差)小于 beta=0.0001 时清仓。逻辑:价格有动量时,标准差会急剧放大;动量消失时,价格回到盘整,标准差收敛。一旦 std 跌破 beta,说明"动量没了",持仓已无意义,立刻平仓。
注意 moveon == False 这个条件:作者注释说"just in case our signal generation time is contraction period, but we dont wanna clear positions right now"——如果这一轮 i 刚好触发了入场(moveon=True),就不清仓,避免同一天既入场又离场的矛盾。
4 层查找共用一个布尔开关 moveon:每层进入前重置为 False,只有上一层的 break 把它点亮 True,下一层才会执行。任何一层没找到节点,moveon 保持 False,后续层全部跳过,这个 i 就不产生信号。这是用"开关链"实现的短路逻辑,比 4 层嵌套 if 紧凑得多。

上图是 W 底形态的可视化:蓝色价格线、青绿色带、虚线中带。绿色三角是入场点(右上节点 i,突破上带),红色倒三角是离场点(带宽收缩)。红色粗折线把 5 个节点 l-k-j-m-i 连起来,画出 W 形。"Expansion"标注在入场处(带宽扩张确认动量),"Contraction"标注在离场处(带宽收缩确认动量消失)。

第二张图展示了持仓随时间的演化,可以直观看到入场与离场的节奏。
🎯 反直觉发现:本节没有 Pair Trading 那种戏剧性的"关系破裂"反转,但有一个更朴素的反直觉——形态识别可以完全不靠机器学习。当大家一窝蜂上 CNN/LSTM 识别 K 线形态时,作者用 4 层 for 循环 + alpha=0.0001 容差就把 W 底抓出来了,而且每一步都可解释、可调参、计算复杂度极低。这提醒我们:在"算术够用"的场合,机器学习往往是过度工程。"为什么用机器学习,当算术更快更简单?"——这句宣言值得每一个量化新人贴在显示器上。
moveon 开关链实现短路:4 层查找共用一个布尔,层层接力,任一层失败则后续全跳过,代码紧凑。np.abs(price - band) < alpha 判定价格是否"贴在带上",太小不触发,太大误触发。std < beta 判定动量消失,与 alpha 语义完全不同,别混用。coordinates 字符串列存 5 节点:'%s,%s,%s,%s,%s' % (l,k,j,m,i),画图时 split 再映射回日期索引,画出 W 形折线。cumsum 既是持仓也是防重复:cumsum[i]==0 保证只在空仓时入场,避免重复开仓。moveon==False 守护收缩清仓:防止"入场与离场撞同一天"的矛盾,体现作者对边界条件的细致处理。moveon 开关链短路。下一节,我们看另一个形态识别——RSI 头肩顶。同样是"找节点",但这次找的是 7 个节点,而且作者对 RSI 这个指标本身毫不客气:"我其实觉得 RSI 有点扯(bs)"。更有趣的是 RSI 发明人 Wilder 与其门徒 Cardwell 在"熊背离"上互相矛盾的公案。