☰
缠论程序化实现:从K线包含到笔、中枢、背驰的量化回测
2026/10/13 8:30:15 网站建设 项目流程

简介:缠论是A股市场颇具争议的技术分析体系,这份资源以Python将缠论中的K线包含处理、顶底分型识别与画笔规则做成可运行脚本,适合对量化交易和缠论自动化的Python开发者参考。项目采用清晰的分层布局,源码、文档与配置相互分离,已实现K线包含处理、找顶底、划笔三个核心模块,同时标出线段划分、均线选股、均线轮动和板块强弱指标等后续扩展方向,便于读者追踪缠论程序化的完整路径。压缩包共33个文件,以21个Python脚本为主体,辅以少量XML配置、TXT说明与Markdown文档,整体约25KB,轻量易读,可直接对照缠论原文梳理代码逻辑。已有898人学习下载,适合有一定Python基础、希望将手工画图流程转译为代码的股票研究者,也可作为进一步实现每日走势分类(30分钟8根K线)等功能的起点。

1. ChZhShCh 缠论程序化:先把“笔”和“中枢”写成代码,解决的是什么问题

ChZhShCh 这套实践,核心是给缠论画出一条可计算的边界:把K线包含处理、分型、笔、中枢和背驰判定这些能明确写成规则的部分,做成可回测的程序,线段和走势类型这类解释空间大的环节先留给人工复核。手工画图和程序画图的差别,在实操里很明显。同一段行情,有人把某一笔拆成三段,有人合并成一段,最后对中枢位置的判断完全不同。程序化的价值不是让机器替你做决策,而是把“哪一根K线被合并、哪一个分型先确认”这些过程固定下来,让每次计算可复现、可回测、可复盘。这套实践适合做量化开发入门、技术分析信号验证,以及想在CTA类策略里引入结构判定的从业者;前提是接受“部分缠论”这个提法,不追求完整还原一套理论,只追求能复现的规则先落地。

2. 缠论程序化从哪一步开始:K线包含处理与分型判定的数据基础

2.1 用高低点合并把包含K线归一化:方向判定是第一道坑

K线包含处理是缠论程序化里最容易写错、也最影响后续结果的一步。两根K线一根的高点更高、低点也更低,或者一根的高点更低、低点更高,这就是包含关系。包含关系的处理规则本身不复杂:向上趋势里取两根K线中较高的高点和较高的低点,向下趋势里取较低的低点和较低的高点。麻烦在于“向上还是向下”不是看当前这两根K线,而是看最近一次非包含K线给出的方向。很多翻车案例都是因为直接用当前K线相对上一根K线的高低价差判方向,在连续包含时方向会反复横跳。

我一般会这样写合并逻辑:

def merge_bars(bars): """bars: [(high, low), ...],按时间升序排列,返回包含处理后的K线列表""" if not bars: return [] merged = [] direction = 1 # 1 表示向上,-1 表示向下,初始值只占位 for i, (high, low) in enumerate(bars): if not merged: merged.append([high, low]) continue last = merged[-1] # 判断当前K线是否与最后一根处理后的K线存在包含 if (high >= last[1] and low <= last[0]) or (high <= last[1] and low >= last[0]): if direction == 1: merged[-1] = [max(last[0], high), max(last[1], low)] else: merged[-1] = [min(last[0], high), min(last[1], low)] else: merged.append([high, low]) # 方向只由最近一次非包含K线决定 if high > last[1] and low > last[0]: direction = 1 elif high < last[1] and low < last[0]: direction = -1 return [tuple(x) for x in merged]

这里的核心不是合并动作本身,而是direction的更新时机。只有当前K线与最后一根处理后的K线不包含时,才更新方向;只要发生包含,方向就沿用上一次的值。这样连续包含时不会因为某根小实体K线而改变合并方向。

这段代码里merged[-1]同时承担两个角色:既是被比较的基准K线,也是合并结果的容器。所以合并时直接原地修改merged[-1],不需要额外维护一个“上一根”变量。参数上没有额外入参,但要注意输入顺序必须严格时间升序,如果数据源返回的是降序,先bars[::-1]再进函数。

2.2 顶底分型与成笔判定:把图形语言变成离散序列

包含处理之后,下一步是找分型。顶分型是中间K线的高点最高、低点也最高,底分型是中间K线的低点最低、高点也最低。这里最关键的是严格不等式:如果出现高点相等或低点相等,这个分型就不成立。放宽成>=会让分型数量明显变多,后续用这些分型构建的笔也会频繁跳动。

def find_fractals(kline): """kline: 包含处理后的 [(high, low), ...]""" fractals = [] for i in range(1, len(kline) - 1): p_high, p_low = kline[i - 1] c_high, c_low = kline[i] n_high, n_low = kline[i + 1] # 顶分型:中间高点最高,且中间低点也最高 if c_high >= p_high and c_high >= n_high and c_low > p_low and c_low > n_low: fractals.append({"type": "top", "index": i, "price": c_high}) # 底分型:中间低点最低,且中间高点也最低 elif c_low <= p_low and c_low <= n_low and c_high < p_high and c_high < n_high: fractals.append({"type": "bottom", "index": i, "price": c_low}) return fractals

得到分型序列后,还不能直接把它们连成笔。缠论的笔要求顶底分型之间必须有足够多的独立K线,而且顶底必须严格交替。如果两个分型同类型,比如连续两个顶分型,一般保留价格更高的那个作为候选顶,再往下找底。

def build_bi(fractals, min_bars=4): """在分型序列上构建笔,min_bars 是两个分型之间的最少独立K线数""" bi = [] candidate = None for f in fractals: if candidate is None: candidate = f continue # 间距不够,不构成笔 if f["index"] - candidate["index"] <= min_bars: continue # 同类型分型:保留更极端的一个 if f["type"] == candidate["type"]: if (f["type"] == "top" and f["price"] > candidate["price"]) or \ (f["type"] == "bottom" and f["price"] < candidate["price"]): candidate = f continue # 顶底交替且价格创新高/新低,才形成一笔 if candidate["type"] == "bottom" and f["type"] == "top" and f["price"] > candidate["price"]: bi.append((candidate, f)) candidate = f elif candidate["type"] == "top" and f["type"] == "bottom" and f["price"] < candidate["price"]: bi.append((candidate, f)) candidate = f return bi

min_bars=4是我在日线上常用的取值,意思是两个分型中间至少隔 4 根独立K线。这个值不是理论原文规定的,原文只要求顶底分型之间至少有一根独立K线。取值越小,笔越密,信号越多但噪声越大;取值越大,笔越稀,中枢和背驰信号都变少。换成小时线或分钟线,min_bars要重新扫,不能沿用日线的经验值。

这段代码里还有一个容易被忽略的细节:当顶底交替但价格没有创新高或新低时,当前分型会被丢弃,candidate保持不变。比如底分型之后出现一个更低的顶分型,但它的价格没有超过前一个底分型的低点,那这个顶不合法,继续保留原有底分型等下一个顶。这个取舍直接影响后续中枢的起点位置。

2.3 线段与走势类型先砍掉:为什么“部分缠论”是一种工程选择

完整缠论里,笔之上还有线段,线段之上还有走势类型。线段由至少三笔构成,但线段终结需要处理特征序列,特征序列又依赖缺口和分型的包含关系。这一层规则不是不能写,而是不同人对特征序列的理解经常不一致。同一个行情,一个人画三笔一段,另一个人画五笔一段,线段端点差出好几个K线位置。程序化最怕这种“同输入不同输出”的规则,一旦写进去,回测结果没法归因。

所以在 ChZhShCh 的落地路径里,我明确把线段和走势类型划到人工复核范围。程序只负责输出笔序列、中枢区间和背驰信号,使用者可以拿着这些中间结果在图上复盘,再决定要不要相信信号。这样做换来的是每一层计算都可追溯:包含处理错了查包含,分型错了查分型,不会出现一个黑匣子式的“走势类型判定”把问题吞掉。

3. 中枢与背驰的量化判定:从连续三笔重叠到MACD面积衰减

3.1 中枢重叠区间这样求,才不会把进入段算进去

有了笔序列之后,中枢判定就变成一个几何问题:连续三笔的重叠区间。具体说,取三笔中最小的“高点”作为中枢上沿,最大的“低点”作为中枢下沿,只有上沿大于下沿时才形成中枢。这里最常见的错误是把进入段也算进重叠,导致中枢区间被拉宽,上下沿失真。

def zhongshu_from_bi(bi_list, min_overlap_bars=3): """bi_list: [(start_price, end_price), ...],每笔用端点价格表示""" zones = [] for i in range(len(bi_list) - min_overlap_bars + 1): window = bi_list[i:i + min_overlap_bars] # 进入段不参与重叠,从窗口第二笔开始取区间 highs = [max(pair) for pair in window[1:]] lows = [min(pair) for pair in window[1:]] zg = min(highs) # 中枢上沿 zd = max(lows) # 中枢下沿 if zg > zd: zones.append((zd, zg)) return zones

min_overlap_bars=3表示至少三笔参与重叠。缠论里中枢通常由至少三笔重叠构成,所以这个参数就是中枢的最低成立条件。如果取 4,中枢会更严格,但很多行情根本凑不出四笔重叠,信号数量会急剧下降。

注意window[1:]这一步。三笔构成一个中枢时,第一笔是进入段,后两笔与中枢内部的笔重叠。如果直接对window全部取min(highs)和max(lows),进入段的极端价格会污染中枢边界。正确做法是把窗口第二笔到第三笔当作中枢重叠区间的主体,进入段只负责连接上一个中枢或确认趋势方向。

这里还有一个边界问题:同一段行情可能连续出现多个重叠区间,比如五笔里任意三笔都重叠,会算出多个中枢。实际落地时我通常取最后一个被完整确认的中枢作为当前信号参照,前面的中枢归入历史状态。这个“只取最后一个”的规则要写死在代码里,否则同一时刻可能出现两个互相矛盾的中枢信号。

3.2 背驰判定:用价格速度与MACD面积的“衰减”代替人工比长度

背驰是缠论里最依赖主观经验的部分。手工看背驰是看两段同向走势的长度和力度对比,比如下跌的进入段和离开段,离开段走得没进入段急,或者MACD绿柱面积明显比进入段小,就叫背驰。程序化以后,我把“没进入段急”翻译成一个可比较的数值:面积衰减或斜率衰减。

def check_chi(enter_area, leave_area, mode="area"): """比较进入段与离开段的MACD力度,返回是否有背驰""" if mode == "area": # 面积模式:离开段MACD柱面积绝对值小于进入段 return abs(leave_area) < abs(enter_area) # 斜率模式:除以区间长度,比较单位时间内的力度 return (leave_area / leave_len) < (enter_area / enter_len)

enter_area是进入段覆盖的MACD柱面积之和,leave_area是离开段覆盖的MACD柱面积之和。面积模式直接比较两者绝对值;斜率模式需要把面积除以各自的K线数量,消除“离开段更长所以面积更大”的干扰。

我只推荐在同一个中枢的前后两段之间比较背驰,不要跨中枢比较。很多人把背驰写成全局比较,结果拿两段相隔很远的走势硬比面积,程序跑出来信号一堆,复盘时每一笔都对不上。缠论的背驰语义天然带位置约束,必须和某个中枢绑定。

MACD参数我一般用 (12, 26, 9),但这不是缠论自己规定的参数,只是技术指标库的默认值。换一个品种、换一个周期,(12, 26, 9) 算出来的面积衰减未必稳定。背驰判定里真正有价值的不是MACD本身,而是“进入段和离开段的力度衰减”这个相对关系,MACD只是承载这个关系的工具。

3.3 从K线到信号输出:一个可调用的迷你流程

到这里,所有组件都齐了。把它们串成一个函数,输入原始K线,输出方向信号或“无信号”。

def signal_from_kline(bars): """bars: [(high, low, close), ...],返回 BUY / SELL / NO_SIGNAL""" # 1. 包含处理,只保留高低点 kline = [(h, l) for h, l, c in bars] merged = merge_bars(kline) # 2. 分型与笔 fractals = find_fractals(merged) bi = build_bi(fractals) if len(bi) < 4: return "NO_SIGNAL" # 3. 找最后一个中枢 zones = zhongshu_from_bi(bi) if not zones: return "NO_SIGNAL" # 4. 背驰比较:倒数第三笔作为进入段,最后一笔作为离开段 enter_price = bi[-3] leave_price = bi[-1] enter_area = macd_area(enter_price) # 这里使用外部实现的MACD面积计算 leave_area = macd_area(leave_price) if check_chi(enter_area, leave_area): if leave_price[1] > leave_price[0]: return "BUY" return "SELL" return "NO_SIGNAL"

这个流程的输入输出很清楚:原始K线进,信号标签出。中间的macd_area需要自己实现,常见做法是先算DIF和DEA,然后对柱状值按K线窗口求和。这里有几个约定必须统一:bi[-3]是进入段,bi[-1]是离开段,中间bi[-2]参与中枢内部结构。信号只在收盘后生成,不能把当根K线的盘中最高最低算进当根信号的判定里。

这个迷你函数已经可以跑通一个简单的信号回测。接下来要解决的是参数设定和数据口径问题,这两件事决定了信号在历史数据上是否可信。

4. 信号回测与参数设定:缠论判定的数据准备和统计口径

4.1 回测数据的最低字段与复权处理

缠论判定只需要价格结构,但回测收益需要真实成交价。数据字段最低要求是时间、开盘价、最高价、最低价、收盘价,成交量是可选项。最高价和最低价用于包含处理和分型判定,开盘价用于模拟次日成交,收盘价用于信号生成和收益计算。

字段用途说明
time时间索引按周期结束时间排序,信号不能跨周期漂移
open次日成交近似信号生成在当日收盘后,第二天开盘价进场
high包含处理分型判定依赖高低点
low包含处理分型判定依赖高低点
close信号生成与收益计算收盘价必须真实,不能用盘中实时价
volume可选过滤用于剔除流动性低的K线

复权问题比很多人想的严重。股票数据如果不复权,除权除息日会留下一个巨大的价格缺口,这个缺口会直接变成一笔虚假的笔,中枢位置被整体拉偏。前复权数据可以解决这个问题,但前复权会随着时间推移不断更新历史价格,同一个历史K线在不同日期拉取可能得到不同的数值。我一般会把回测用的复权数据固化成一份本地快照,避免每次回测拉到的历史价格都不一样。

4.2 信号生成与回测统计:把信号写在收盘后,用次日开盘价成交

缠论信号天然适合收盘后生成。因为分型和笔的确认需要完整K线,当根K线没收盘时,最高价和最低价还在变化,基于它们算出的分型随时可能被推翻。正确的做法是:第 N 根K线收盘后计算信号,第 N+1 根K线开盘价成交。

def run_backtest(df): """df 必须含 signal 列,signal 在当日收盘后生成,成交在次日开盘""" position = 0 equity = [] for i in range(1, len(df)): prev = df.iloc[i - 1] row = df.iloc[i] # 用前一日的信号,在今日开盘价成交 if prev["signal"] == "BUY" and position == 0: position = 1 entry_price = row["open"] elif prev["signal"] == "SELL" and position == 1: position = 0 # 每日收益:持仓时跟随涨跌,空仓时为零 daily_ret = row["close"] / prev["close"] - 1.0 if equity: equity.append(equity[-1] * (1.0 + position * daily_ret)) else: equity.append(1.0 * (1.0 + position * daily_ret)) return equity

注意循环里取的是prev["signal"],不是row["signal"]。这行代码直接决定了回测有没有引入未来函数。很多新手在这里顺手写成row["signal"],等于用当天收盘后算出的信号去成交当天的开盘价,收益被系统性高估,回测曲线漂亮得不像话,实盘一跑就露馅。

回测统计不能只看胜率。缠论信号频次低,一个品种日线一年可能才十几次完整信号,胜率偶然性太大。我一般会同时看三组数:信号次数、平均盈亏比、最大回撤。胜率低于40%但盈亏比大于2的策略仍然可做,相反胜率70%但单笔亏损吃掉前面六笔盈利的策略反而危险。

4.3 三个必调参数:成笔最少K线、中枢重叠段数、MACD参数窗口

缠论程序化真正需要反复调的参数不多,但每一个都直接改变信号结构。

参数作用域调整方式
min_bars成笔最少独立K线数日线默认 4;改大信号变少变稳,改小信号变多变噪
min_overlap_bars中枢至少重叠笔数3 是最低要求;4 更严格但信号稀缺
macd_windowMACD快慢线与信号窗口常见 (12, 26, 9);换品种必须重新扫

min_bars是最敏感的参数。它从 4 改成 5,看起来只是多过滤一根K线,实际效果可能让整段中枢消失,因为笔的端点位置变了。调这个参数时不要盯着胜率找最优值,而是找参数平台的中间值:把同一段数据用 3、4、5、6 分别跑一遍,如果只有某个值表现特别好、旁边两个值都很差,这往往是过拟合,不是真实规律。

MACD参数窗口通常不碰 (12, 26, 9),但背驰比较的是面积相对大小,参数变化会影响面积数值。我的习惯是先固定min_bars和min_overlap_bars,单独扫 MACD 参数,扫完再看 MACD 面积衰减在不同窗口下是否稳定。如果换一个窗口背驰结论就从有变无,说明这个信号本身不扎实。

5. 缠论程序化的避坑清单:未来函数、K线合并顺序与中枢区间歧义

5.1 同一天数据,昨天和今天画出的笔不一样:未来函数在作怪

现象:同一个品种、同一段K线,昨天程序画出一笔从 A 到 B,今天再跑同一段数据,这笔突然变成了 A 到 C,B 点消失。用手工画图的人也有类似体验,但程序化里这个现象尤其隐蔽。

原因:分型和笔的确认依赖后续K线。顶分型在第三根K线出现时只能算是“候选”,需要后面再走几根K线才能确认它没有被破坏。如果你的程序每次都用全量最新数据重算所有历史笔,那么新K线的出现可能改变旧分型的有效性,导致历史笔被重画。

解决:固定数据窗口做快照重算,或者只重算最后一个未确认的分型。我现在的做法是:信号只在K线收盘后计算一次,比如 15 分钟周期只在每根K线收盘时跑一次signal_from_kline,盘中不重算。回测时更严格,统一用end_time截断数据,保证每一根K线使用的信息量不超过它当时能看到的范围。

5.2 连续包含之后高低点方向反了:合并顺序错误

现象:出现连续三四根包含关系的K线时,合并后的K线高低点方向明显不对,连外行看图都觉得这笔画反了。比如明明是下跌趋势中的包含,合并后高点反而抬高了。

原因:合并方向依赖最近一次非包含K线的涨跌方向。如果代码里用当前K线相对上一根K线的方向来决定合并,在连续包含时会反复翻转方向,最终按错误方向做了高低点合并。

解决:在merge_bars里把方向更新放在非包含分支中,只有不包含时才用高低价差刷新方向,包含分支只沿用已有方向。这个逻辑我在 2.1 的代码里已经体现,但实现时很容易被“看起来更直观”的写法替代。验证方法很简单:找一段明显的连续包含行情,手工画一遍合并结果,再和程序输出对比。

5.3 中枢区间出现上下沿颠倒或跨越全图:中枢语义没统一

现象:程序输出的中枢上下沿区间异常,要么zg <= zd被当作有效中枢,要么中枢区间宽到覆盖了整段行情,回测里挂在图上一看就离谱。

原因:中枢的重叠区间语义不清。有人用窗口内所有笔的高低点求重叠,把进入段也包含进去;有人用窗口内全部K线求最高最低,而不是第三笔到第N笔的重叠;还有人把“三笔重叠”理解成“三根K线重叠”,完全偏离了笔中枢的结构。

解决:统一用“进入段不参与重叠,从窗口第二笔开始取min(highs)和max(lows)”的公式,见 3.1 的zhongshu_from_bi。同时在中枢函数里加一道校验:zg > zd不成立就丢弃,不作为有效中枢。我习惯在输出里保留zg和zd两个字段,复盘时直接看数值是否合理,而不是只给一个信号。

5.4 历史回测胜率虚高,实盘却失效:信号太少与参数过拟合

现象:某开发者拿着同一套参数在历史数据上跑出漂亮的胜率,换到最近一段实盘数据上连续止损,最后怀疑整个缠论程序化方向不可行。

原因:缠论信号天生低频,日线上一个品种一年可能只有十几笔完整信号。十几笔样本里挑出表现最好的参数组合,等于在噪声里画靶子。回测出的最优参数换一个时间段往往完全失效。

解决:先看信号次数,信号少于 30 次的结果一律不做参考。调参时不要找胜率峰值,而是找参数“平台”:把参数从 3 到 7 逐个测试,如果连续多个值的结果差异不大,说明这个方向有稳定性;如果只有某一个值特别好,旁边马上变差,直接放弃。还可以把数据切成前后两段,前段调参、后段验证,避免用同一段数据既调参又评价。

5.5 换一个周期,信号全变了:笔的过滤没有跨周期校验

现象:日线跑出 BUY 信号,换到 30 分钟线却连一个中枢都没画出来;或者 30 分钟线信号频繁,日线上完全没动静。两个周期的结论互相矛盾,不知道该信哪个。

原因:不同周期的K线噪声结构不同。日线一根K线包含的信息密度远大于分钟线,同样的min_bars=4在日线上过滤掉了大量小波动,在分钟线上却仍然放进了很多短笔。笔的端点位置变了,中枢区间随之改变,背驰信号自然对不上。

解决:周期切换时不能只换K线数据、不换参数。要么对不同周期单独扫描min_bars,要么引入多周期同构校验:只有小周期信号与大周期最近一笔方向一致时才保留信号。这个校验在第 6 章会给出具体做法,它是缠论程序化里最实用的过滤手段之一。

6. 多周期同构过滤:缠论信号验证里最值得先做的一个技巧

多周期同构的意思是:日线发出的 BUY 信号,必须得到周线上最近一笔向上走势的确认;如果周线最近一笔还在向下,日线的买点先扣住不放。这个技巧能过滤掉大量“小周期结构到位、大周期方向相反”的翻车信号。

def filter_by_sync(day_signal, week_signal): """day_signal 是日线信号,week_signal 是周线最近一笔的方向""" if day_signal in ("BUY", "SELL") and day_signal == week_signal: return day_signal return "HOLD"

实现上有个关键点:周线信号也必须用收盘后确认的笔,不能用未完成笔。我一般把周线信号计算放在周五收盘后的数据快照上,和日线信号分开跑,之后再做比对。

参数建议取值说明
周期对日线 + 周线长期趋势过滤用周线,短线交易可换成 30 分钟 + 日线
同构规则大周期最近一笔方向与信号一致不一致时输出 HOLD,不强行反向
确认延迟大周期笔收盘后 1 根K线避免使用尚未走完的当周K线

这个技巧还有个附加好处:它强迫你把自己的中间结果暴露出来。每次信号被过滤掉,你都能在图上看到是周线哪一笔挡住了它,而不是面对一个只输出买卖点的黑匣子。我现在做缠论程序化,无论信号在参数上多漂亮,都会先把周线笔画出来看一眼:日线的买点和周线一笔方向相反就直接过滤,同向才进入候选池。这个习惯帮我挡掉了不少在回测里看起来很美、实盘里却很别扭的信号。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询